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

feat(instances): a VM's network devices as cards, each with its QoS limits

Settings > Network was a table whose fields were read-only inputs cut
off at phone width, an unlabelled "+", three icon buttons per NIC and
a separate QoS table keyed by MAC. The owner chose card layout from
three rendered options, with the QoS limits folding out like the
device details:

- A card per NIC: a VM's only NIC spans the row, two or more sit side
  by side from md. Each has its name and target, the link state as a
  switch, its source and IPv4 addresses, and "Device details"
  (type, MAC, model, NWFilter, IPv6) in a <details>.
- "QoS limits" in a second <details>, its summary naming the limited
  directions: a form per inbound or outbound limit, with Save and
  Delete limit, and the units.
- Labelled Edit, Add QoS and Delete in the card footer; "Add network
  device" by the heading. The edit and add-QoS dialogs are titled and
  labelled, and the edit dialog's fields have labels.

The limits come from each NIC of the persistent definition, so the QoS
table, Instance.config_qos and wvmInstance.get_all_qos are removed.
This commit is contained in:
catborise 2026-10-30 07:59:11 +03:00
parent 0f778fe9f0
commit a8f445d580
11 changed files with 184 additions and 311 deletions

View file

@ -168,11 +168,6 @@ class Instance(models.Model):
edit does not post the live value back over it."""
return self.proxy.get_disk_devices(config=True)
@cached_property
def config_qos(self):
"""QoS of the persistent definition, which the QoS form changes."""
return self.proxy.get_all_qos(config=True)
@cached_property
def config_vcpu(self) -> int:
"""vCPUs of the persistent definition, which the CPU form changes."""

View file

@ -1,16 +1,14 @@
{% load i18n %}
{% load bootstrap_icons %}
{% if request.user.is_superuser %}
<button type="button" class="btn btn-primary float-end" data-bs-toggle="modal" data-bs-target="#addInstanceNetwork">
<span aria-hidden="true">{% bs_icon 'plus'%} </span>
</button>
<button type="button" class="btn btn-sm btn-primary" data-bs-toggle="modal" data-bs-target="#addInstanceNetwork">{% bs_icon 'plus' %} {% trans "Add network device" %}</button>
<!-- Modal pool -->
<div class="modal fade" id="addInstanceNetwork" tabindex="-1" role="dialog" aria-labelledby="addInstanceNetworkLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{% trans "Add Instance Network" %}</h5>
<h5 class="modal-title" id="addInstanceNetworkLabel">{% trans "Add Instance Network" %}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form action="{% url 'instances:add_network' instance.id %}" method="post" role="form" aria-label="Add instance network form">{% csrf_token %}

View file

@ -0,0 +1,26 @@
{% load i18n %}
{# one QoS limit of a NIC: its values to change, or to delete #}
<form class="mt-2" style="max-width: 32rem" action="{% url 'instances:set_qos' instance.id %}" method="post" aria-label="{% blocktrans %}{{ direction }} QoS form{% endblocktrans %}">{% csrf_token %}
<input type="hidden" name="net-mac" value="{{ network.mac }}">
<input type="hidden" name="qos_direction" value="{{ direction }}">
<div class="fw-semibold mb-1">{% if direction == "inbound" %}{% trans "Inbound" %}{% else %}{% trans "Outbound" %}{% endif %}</div>
<div class="row g-2">
<div class="col-4">
<label class="form-label small" for="qos-{{ nic }}-{{ direction }}-average">{% trans "Average" %}</label>
<input class="form-control form-control-sm" id="qos-{{ nic }}-{{ direction }}-average" name="qos_average" value="{{ limit.average|default:'' }}" required pattern="[0-9]+">
</div>
<div class="col-4">
<label class="form-label small" for="qos-{{ nic }}-{{ direction }}-peak">{% trans "Peak" %}</label>
<input class="form-control form-control-sm" id="qos-{{ nic }}-{{ direction }}-peak" name="qos_peak" value="{{ limit.peak|default:'' }}" pattern="[0-9]+">
</div>
<div class="col-4">
<label class="form-label small" for="qos-{{ nic }}-{{ direction }}-burst">{% trans "Burst" %}</label>
<input class="form-control form-control-sm" id="qos-{{ nic }}-{{ direction }}-burst" name="qos_burst" value="{{ limit.burst|default:'' }}" pattern="[0-9]+">
</div>
</div>
<div class="d-flex flex-wrap gap-2 mt-2">
<button type="submit" class="btn btn-sm btn-primary" name="set_qos">{% trans "Save" %}</button>
<button type="submit" class="btn btn-sm btn-danger" name="unset_qos" formaction="{% url 'instances:unset_qos' instance.id %}" formnovalidate
data-confirm="{% blocktrans with mac=network.mac %}Remove the {{ direction }} limit of {{ mac }}? A running VM keeps it until it is powered off and on.{% endblocktrans %}" onclick="return confirm(this.dataset.confirm)">{% trans "Delete limit" %}</button>
</div>
</form>

View file

@ -296,246 +296,127 @@
</section>
</div>
<div role="tabpanel" class="tab-pane" id="network">
<div class="mb-3">
{% trans "Add a network device" %}
{% include 'add_instance_network_block.html' %}
</div>
<div class="row mt-3">
<div class="col-lg-12 mt-3">
<h5 class="fw-bold">{% trans "Network Devices" %}</h5>
<div class="table-responsive">
<table align-middle class="table">
<thead>
<tr>
<th scope="col">{% trans 'Name' %}</th>
<th scope="col" class="d-none d-table-cell d-sm-table-cell" colspan="2">{% trans 'Info' %}</th>
<th scope="colgroup" colspan="3" class="col-sm-2">{% trans 'Actions' %}</th>
</tr>
</thead>
<tbody>
{% for network in instance.config_networks %}
<tr>
<td rowspan="2">eth{{ forloop.counter0 }}({{ network.target|default:"no target" }})
<form action="{% url 'instances:set_link_state' instance.id %}" method="post" aria-label="set instance link state form">
{% csrf_token %}
<input name="mac" value="{{ network.mac }}" hidden/>
<input name="set_link_state" value="{{ network.state }}" hidden/>
<input type="checkbox" {% if network.state == 'up' %} checked{% endif %} onclick='submit();' />
<strong>{% trans 'active' %}</strong>
<small>{{ network.type }}</small>
</form>
</td>
<td>
<div class="input-group">
<span class="input-group-text">{% trans 'MAC' %}</span>
<input type="text" class="form-control" value="{{ network.mac }}" aria-label="mac" aria-describedby="mac" readonly>
</div>
</td>
<td class="d-none d-table-cell">
<div class="input-group">
<span class="input-group-text">{% trans 'Filter' %}</span>
<input type="text" class="form-control" value="{{ network.filterref|default:'None' }}" aria-label="filter" aria-describedby="filter" readonly>
</div>
</td>
<td>
<div class="input-group">
<span class="input-group-text">{% trans 'Source' %}</span>
<input type="text" class="form-control" value="{{ network.nic }}" aria-label="source" aria-describedby="source" readonly>
</div>
</td>
<td>
{# a NIC that is a host device is changed in the XML editor #}
{% if network.type != "hostdev" %}
<form action="{% url 'instances:change_network' instance.id %}" method="post" name="edit_network{{ forloop.counter0 }}" role="form">{% csrf_token %}
<button class="btn btn-sm btn-primary"
type="button"
title="{% trans "Edit NIC" %}"
data-bs-target="#editInstanceNetwork{{ forloop.counter0 }}"
data-bs-toggle="modal">
<span aria-hidden="true">{% bs_icon 'pencil-square' %} </span>
</button>
<div class="modal fade" id="editInstanceNetwork{{ forloop.counter0 }}" role="dialog" aria-labelledby="editInstanceNetworkLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{% trans "Edit Instance Network" %}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="container">
<div class="row">
<label class="col-form-label">{% trans "MAC" %}</label>
<div class="input-group">
<input class="form-control" type="text" name="net-old-mac-{{ forloop.counter0 }}" value="{{ network.mac }}" readonly/>
<input class="form-control" type="text" name="net-mac-{{ forloop.counter0 }}" value="{{ network.mac }}"/>
</div>
</div>
<div class="row">
<label class="col-form-label">{% trans "Net Source" %}</label>
<div class="input-group">
<input class="form-control" type="text" value="{{ network.nic }}" readonly/>
<select class="form-select" name="net-source-{{ forloop.counter0 }}">
{% for c_net in networks_host %}
<option value="net:{{ c_net }}" {% if c_net == network.nic %} selected {% endif %}>{% trans 'Network' %} {{ c_net }}</option>
{% endfor %}
{% for c_iface in interfaces_host %}
<option value="iface:{{ c_iface }}" {% if c_iface == network.nic %} selected {% endif %}>{% trans 'Interface' %} {{ c_iface }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="row">
<label class="col-form-label">{% trans "NWFilter" %}</label>
<div class="input-group">
<input class="form-control" type="text" value="{{ network.filterref }}" readonly/>
<select class="form-select" name="net-nwfilter-{{ forloop.counter0 }}">
<option value="">{% trans "None" %}</option>
{% for c_filters in nwfilters_host %}
<option value="{{ c_filters }}" {% if c_filters == network.filterref %} selected {% endif %}>{{ c_filters }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="row">
<label class="col-form-label">{% trans "Model" %} </label>
<div class="input-group">
<input class="form-control" type="text" value="{{ network.model }}" readonly/>
<select class="form-select" name="net-model-{{ forloop.counter0 }}">
{% for model in net_models_host %}
<option value="{{ model }}" {% if model == network.model %} selected {% endif %}>{{ model }}</option>
{% endfor %}
</select>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" type="button" data-bs-dismiss="modal">{% trans 'Close' %}</button>
<button class="btn btn-primary" name="change_network" title="{% trans "Apply network changes" %}">{% trans "Apply" %}</button>
</div>
</div>
</div>
{# a card per NIC, the only one across the row; its QoS limits fold out like its device details #}
<section aria-labelledby="nics-heading">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-2">
<h2 class="h6 mb-0" id="nics-heading">{% trans "Network devices" %} <span class="text-body-secondary fw-normal">· {{ instance.config_networks|length }}</span></h2>
{% include 'add_instance_network_block.html' %}
</div>
<div class="row g-3">
{% for network in instance.config_networks %}
<div class="col-12{% if instance.config_networks|length > 1 %} col-md-6{% endif %}">
<article class="card h-100" aria-labelledby="nic-{{ forloop.counter0 }}">
<div class="card-body small">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-2">
<h3 class="h6 mb-0" id="nic-{{ forloop.counter0 }}">{% bs_icon 'ethernet' %} eth{{ forloop.counter0 }} <span class="text-body-secondary fw-normal">· {{ network.target|default:_("no target") }}</span></h3>
<form action="{% url 'instances:set_link_state' instance.id %}" method="post" aria-label="{% trans "Link state form" %}">{% csrf_token %}
<input type="hidden" name="mac" value="{{ network.mac }}">
<input type="hidden" name="set_link_state" value="{{ network.state }}">
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" role="switch" id="link-{{ forloop.counter0 }}"{% if network.state == 'up' %} checked{% endif %} onchange="this.form.submit()">
<label class="form-check-label" for="link-{{ forloop.counter0 }}">{% if network.state == 'up' %}{% trans "Link up" %}{% else %}{% trans "Link down" %}{% endif %}</label>
</div>
</form>
{% endif %}
</td>
<td>
<form action="{% url 'instances:set_qos' instance.id %}" method="post" name="add_qos{{ forloop.counter0 }}" role="form" aria-label="Add network qos form">
{% csrf_token %}
<input type="text" name="net-mac-{{ forloop.counter0 }}" value="{{ network.mac }}" hidden/>
{% include 'add_network_qos.html' with id=forloop.counter0 %}
</form>
</td>
<td>
{% if network.type != "hostdev" %}
<form action="{% url 'instances:delete_network' instance.id %}" method="post" name="delete_network" role="form">{% csrf_token %}
<button class="btn btn-sm btn-danger" value="{{ network.mac }}" name="delete_network" title="{% trans "Delete Device" %}"
data-confirm="{% blocktrans with mac=network.mac %}Remove network card {{ mac }}? A running VM loses its connection through it at once.{% endblocktrans %}" onclick="return confirm(this.dataset.confirm)">
{% bs_icon 'trash' %}
</button>
</form>
{% endif %}
</td>
</tr>
<tr>
<td>
<div class="input-group">
<span class="input-group-text">{% trans 'IPv4' %}</span>
<input type="text" class="form-control" value="{% for ipv4 in network.ipv4|default:'unknown' %}{{ ipv4 }}{% endfor %}" aria-label="ipv4" aria-describedby="ipv4" readonly>
</div>
<p class="text-break mb-1"><span class="text-body-secondary">{% trans "Source" %}</span> <strong class="ms-1">{{ network.nic|default:"—" }}</strong></p>
<p class="mb-2"><span class="text-body-secondary">{% trans "IPv4" %}</span> <span class="ms-1">{{ network.ipv4|join:", "|default:_("unknown") }}</span></p>
<details>
<summary class="text-body-secondary">{% trans "Device details" %}</summary>
<dl class="row mb-0 mt-2" style="max-width: 32rem">
<dt class="col-4 fw-normal text-body-secondary">{% trans "Type" %}</dt><dd class="col-8 mb-1">{{ network.type }}</dd>
<dt class="col-4 fw-normal text-body-secondary">{% trans "MAC" %}</dt><dd class="col-8 mb-1 text-break">{{ network.mac }}</dd>
<dt class="col-4 fw-normal text-body-secondary">{% trans "Model" %}</dt><dd class="col-8 mb-1">{{ network.model|default:"—" }}</dd>
<dt class="col-4 fw-normal text-body-secondary">{% trans "NWFilter" %}</dt><dd class="col-8 mb-1 text-break">{{ network.filterref|default:_("None") }}</dd>
<dt class="col-4 fw-normal text-body-secondary">{% trans "IPv6" %}</dt><dd class="col-8 mb-1 text-break">{{ network.ipv6|join:", "|default:_("unknown") }}</dd>
</dl>
</details>
<details>
<summary class="text-body-secondary">{% trans "QoS limits" %} · {% if network.inbound and network.outbound %}{% trans "inbound, outbound" %}{% elif network.inbound %}{% trans "inbound" %}{% elif network.outbound %}{% trans "outbound" %}{% else %}{% trans "none" %}{% endif %}</summary>
{% if network.inbound %}{% include 'instances/nic_qos_form.html' with nic=forloop.counter0 direction="inbound" limit=network.inbound %}{% endif %}
{% if network.outbound %}{% include 'instances/nic_qos_form.html' with nic=forloop.counter0 direction="outbound" limit=network.outbound %}{% endif %}
<p class="text-body-secondary mt-2 mb-0">{% trans "Average and peak are in KiB/s, burst in KiB. A running VM gets a change after it is powered off and on." %}</p>
</details>
{% if network.type == 'direct' %}
<p class="text-body-secondary border-top pt-2 mt-2 mb-0">{% bs_icon 'info-circle' %} {% trans "In most configurations, macvtap does not work for host to guest network communication" %}</p>
{% endif %}
</div>
<div class="card-footer bg-transparent d-flex flex-wrap gap-2">
{# a NIC that is a host device is changed in the XML editor #}
{% if network.type != "hostdev" %}
<button class="btn btn-sm btn-secondary" type="button" data-bs-toggle="modal" data-bs-target="#editInstanceNetwork{{ forloop.counter0 }}">{% trans "Edit" %}</button>
{% endif %}
<button class="btn btn-sm btn-secondary" type="button" data-bs-toggle="modal" data-bs-target="#AddQos{{ forloop.counter0 }}">{% trans "Add QoS" %}</button>
{% if network.type != "hostdev" %}
<form action="{% url 'instances:delete_network' instance.id %}" method="post" aria-label="{% trans "Delete network device form" %}">{% csrf_token %}
<button class="btn btn-sm btn-danger" value="{{ network.mac }}" name="delete_network"
data-confirm="{% blocktrans with mac=network.mac %}Remove network card {{ mac }}? A running VM loses its connection through it at once.{% endblocktrans %}" onclick="return confirm(this.dataset.confirm)">{% trans "Delete" %}</button>
</form>
{% endif %}
</div>
</article>
{% if network.type != "hostdev" %}
<form action="{% url 'instances:change_network' instance.id %}" method="post" name="edit_network{{ forloop.counter0 }}">{% csrf_token %}
<div class="modal fade" id="editInstanceNetwork{{ forloop.counter0 }}" tabindex="-1" aria-labelledby="editInstanceNetworkLabel{{ forloop.counter0 }}" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="editInstanceNetworkLabel{{ forloop.counter0 }}">{% blocktrans with nic=forloop.counter0 %}Edit eth{{ nic }}{% endblocktrans %}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% trans "Close" %}"></button>
</div>
</td>
<td>
<div class="input-group">
<span class="input-group-text">{% trans 'IPv6' %}</span>
<input type="text" class="form-control" value="{% for ipv6 in network.ipv6|default:'unknown' %}{{ ipv6 }}{% endfor %}" aria-label="ipv6" aria-describedby="ipv6" readonly>
<div class="modal-body">
<input type="hidden" name="net-old-mac-{{ forloop.counter0 }}" value="{{ network.mac }}">
<div class="mb-3">
<label class="form-label" for="net-mac-{{ forloop.counter0 }}">{% trans "MAC" %}</label>
<input class="form-control" type="text" id="net-mac-{{ forloop.counter0 }}" name="net-mac-{{ forloop.counter0 }}" value="{{ network.mac }}">
</div>
<div class="mb-3">
<label class="form-label" for="net-source-{{ forloop.counter0 }}">{% trans "Net Source" %}</label>
<select class="form-select" id="net-source-{{ forloop.counter0 }}" name="net-source-{{ forloop.counter0 }}">
{% for c_net in networks_host %}
<option value="net:{{ c_net }}"{% if c_net == network.nic %} selected{% endif %}>{% trans 'Network' %} {{ c_net }}</option>
{% endfor %}
{% for c_iface in interfaces_host %}
<option value="iface:{{ c_iface }}"{% if c_iface == network.nic %} selected{% endif %}>{% trans 'Interface' %} {{ c_iface }}</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label class="form-label" for="net-nwfilter-{{ forloop.counter0 }}">{% trans "NWFilter" %}</label>
<select class="form-select" id="net-nwfilter-{{ forloop.counter0 }}" name="net-nwfilter-{{ forloop.counter0 }}">
<option value="">{% trans "None" %}</option>
{% for c_filters in nwfilters_host %}
<option value="{{ c_filters }}"{% if c_filters == network.filterref %} selected{% endif %}>{{ c_filters }}</option>
{% endfor %}
</select>
</div>
<div>
<label class="form-label" for="net-model-{{ forloop.counter0 }}">{% trans "Model" %}</label>
<select class="form-select" id="net-model-{{ forloop.counter0 }}" name="net-model-{{ forloop.counter0 }}">
{% for model in net_models_host %}
<option value="{{ model }}"{% if model == network.model %} selected{% endif %}>{{ model }}</option>
{% endfor %}
</select>
</div>
</div>
</td>
<td colspan="3">
<div class="input-group">
<span class="input-group-text px-2">{% trans 'Model' %}</span>
<input type="text" class="form-control px-2" value="{{ network.model }}" aria-label="model" aria-describedby="model" readonly>
<div class="modal-footer">
<button class="btn btn-secondary" type="button" data-bs-dismiss="modal">{% trans 'Close' %}</button>
<button class="btn btn-primary" name="change_network">{% trans "Apply" %}</button>
</div>
</td>
</tr>
<tr>
<td class="py-1 bg-primary text-white" colspan="9">
{% if network.type == 'direct' %}
<small>{% trans 'In most configurations, macvtap does not work for host to guest network communication' %}</small>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% if instance.config_qos %}
<div class="row mt-3">
<div class="col-sm-10">
<p><strong>{% trans "QoS Configuration" %}</strong></p>
</div>
<div class="col-12 col-sm-12">
<div class="table-responsive">
<table class="table table-hover align-middle">
<thead>
<tr class="d-flex">
<th scope="col" class="col-2">{% trans "MAC" %}/{% trans "Direction" %}</th>
<th scope="col" class="col-2">{% trans "Average" %}</th>
<th scope="col" class="col-3">{% trans "Peak" %}</th>
<th scope="col" class="col-3">{% trans "Burst" %}</th>
<th scope="col" class="col-2">{% trans "Actions" %}</th>
</tr>
</thead>
<tbody>
{% for q, attrs in instance.config_qos.items %}
{% for att in attrs %}
<tr class="d-flex">
<form action="{% url 'instances:set_qos' instance.id %}" method="post" role="form" aria-label="Instance QoS configuration form">
{% csrf_token %}
<td class="col-2"><label class="col-form-label">{{ q }} {{ att.direction | capfirst }}</label></td>
<td class="col-2"><input id="qos_average" class="form-control" name="qos_average"
value="{{ att.average|default:'' }}"/>
</td>
<td class="col-3"><input id="qos_peak" class="form-control" name="qos_peak"
value="{{ att.peak|default:'' }}"/>
</td>
<td class="col-3"><input id="qos_burst" class="form-control" name="qos_burst"
value="{{ att.burst|default:'' }}"/>
</td>
<td class="col-sm-2">
<input name="qos_direction" value="{{ att.direction }}" hidden/>
<input name="net-mac" value="{{ q }}" hidden/>
<button type="submit" class="btn btn-sm btn-primary"
name="set_qos" data-bs-toggle="modal"
title="{% trans "Edit QoS" %}">
<span>{% bs_icon 'save' %} </span>
</button>
</form>
<form action="{% url 'instances:unset_qos' instance.id %}" method="post" role="form" aria-label="Instance QoS configuration form">
{% csrf_token %}
<input name="qos_direction" value="{{ att.direction }}" hidden/>
<input name="net-mac" value="{{ q }}" hidden/>
<button type="submit" class="btn btn-sm btn-danger"
name="unset_qos"
title="{% trans "Delete QoS" %}" data-confirm="{% blocktrans with direction=att.direction mac=q %}Remove the {{ direction }} limit of {{ mac }}? A running VM keeps it until it is powered off and on.{% endblocktrans %}" onclick="return confirm(this.dataset.confirm)">
{% bs_icon 'trash' %}
</button>
</form>
</td>
</tr>
{% endfor %}
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</form>
{% endif %}
<form action="{% url 'instances:set_qos' instance.id %}" method="post" name="add_qos{{ forloop.counter0 }}" aria-label="{% trans "Add network QoS form" %}">{% csrf_token %}
<input type="hidden" name="net-mac" value="{{ network.mac }}">
{% include 'add_network_qos.html' with id=forloop.counter0 %}
</form>
</div>
{% empty %}
<p class="small text-body-secondary mb-0">{% trans "The VM has no network device." %}</p>
{% endfor %}
</div>
</div>
{% endif %}
<div class="clearfix"></div>
</section>
</div>
{% if show_hostdevs %}
<div role="tabpanel" class="tab-pane" id="hostdevs">

View file

@ -602,9 +602,9 @@ class LiveDataLossTestCase(TestCase):
def row():
page = html.fromstring(self.client.get(reverse("instances:instance", args=[inst.id])).content)
(label,) = page.xpath("//label[@class='col-form-label'][contains(., 'Inbound')]")
fields = label.xpath("ancestor::tr[1]//input[@name]") # the browser posts the row's inputs
return {f.get("name"): f.get("value") for f in fields}
# the browser posts the inputs of the NIC's inbound limit form
(form,) = page.xpath("//form[input[@name='qos_direction'][@value='inbound']]")
return {f.get("name"): f.get("value") for f in form.xpath(".//input[@name!='csrfmiddlewaretoken']")}
self.post("set_qos", inst, {**row(), "qos_average": "1000"})
form = row()

View file

@ -28,8 +28,8 @@ class PendingEditsTestCase(TestCase):
compute = Compute.objects.create(name="pe", hostname="127.0.0.1:1", login="root", password="", type=1)
self.instance = Instance.objects.create(compute=compute, name="pe-vm", uuid="aaaaaaaa-1111-2222-3333-444444444444")
def page(self, live=(), config=(), qos=({}, {}), status=1, **getters):
"""live and config: disks; qos and getters (get_vcpu=(2, 4)): values of
def page(self, live=(), config=(), nics=((), ()), status=1, **getters):
"""live and config: disks; nics and getters (get_vcpu=(2, 4)): values of
the live and the persistent definition."""
with patch("instances.models.wvmInstance") as wvm, patch.object(
Compute, "status", new_callable=PropertyMock, return_value=True
@ -44,12 +44,12 @@ class PendingEditsTestCase(TestCase):
getattr(proxy, getter).side_effect = lambda config=False, values=values: values[config]
proxy.get_max_cpus.return_value = [1, 2, 3, 4]
proxy.get_vcpus.return_value = {}
for getter in ("get_networks", "get_ifaces", "get_storages", "get_media_devices", "get_net_devices",
for getter in ("get_networks", "get_ifaces", "get_storages", "get_media_devices",
"get_snapshot", "get_snapshot_jobs"):
getattr(proxy, getter).return_value = []
proxy.get_hostdevs.return_value = ([], [], [])
proxy.get_disk_devices.side_effect = lambda **kw: config if kw.get("config") else live
proxy.get_all_qos.side_effect = lambda **kw: qos[1] if kw.get("config") else qos[0]
proxy.get_net_devices.side_effect = lambda config=False: [dict(nic) for nic in nics[config]]
proxy.get_console_type.return_value = "vnc"
proxy.get_video_models.return_value = ["vga", "virtio"]
proxy.get_cache_modes.return_value = {"default": "Default", "none": "Disabled"}
@ -70,13 +70,24 @@ class PendingEditsTestCase(TestCase):
self.assertEqual(vda.xpath("string(.//input[@name='vol_serial']/@value)"), "")
def test_the_qos_form_shows_the_pending_definition(self):
mac = "52:54:10:00:00:01"
inbound = {"direction": "inbound", "average": "500", "peak": "0", "burst": "0", "floor": None}
doc = self.page(qos=({mac: [inbound]}, {mac: [{**inbound, "average": "1000"}, {**inbound, "direction": "outbound"}]}))
nic = {"type": "network", "mac": "52:54:10:00:00:01", "nic": "default", "state": "up", "model": "virtio",
"ipv4": None, "ipv6": None, "filterref": "", "target": "vnet0", "outbound": [],
"inbound": {"average": "500", "peak": "0", "burst": "0"}}
pending = {**nic, "inbound": {"average": "1000", "peak": "0", "burst": "0"}, "outbound": nic["inbound"]}
doc = self.page(nics=([nic], [pending]))
# the pending inbound value, and the pending outbound limit has a form
self.assertEqual(doc.xpath("//input[@name='qos_average']/@value"), ["1000", "500"])
# the pending outbound has a row
self.assertEqual([" ".join(t.split()) for t in doc.xpath("//label[@class='col-form-label']/text()")],
[f"{mac} Inbound", f"{mac} Outbound"])
self.assertEqual(doc.xpath("//form[.//input[@name='qos_average']]//input[@name='qos_direction']/@value"),
["inbound", "outbound"])
def test_one_nic_card_spans_the_row_and_more_sit_side_by_side(self):
nic = {"type": "network", "mac": "52:54:10:00:00:01", "nic": "default", "state": "up", "model": "virtio",
"ipv4": None, "ipv6": None, "filterref": "", "target": "", "inbound": [], "outbound": []}
second = {**nic, "mac": "52:54:10:00:00:02"}
for nics, wide in (([nic], ["col-12"]), ([nic, second], ["col-12 col-md-6"] * 2)):
with self.subTest(count=len(nics)):
doc = self.page(nics=(nics, nics))
self.assertEqual(doc.xpath("//div[@id='network']//div[article]/@class"), wide)
def test_one_disk_card_spans_the_row_and_more_sit_side_by_side(self):
for disks, wide in (([disk("vda", "default", "")], ["col-12"]),

View file

@ -1012,7 +1012,7 @@ class InstancesTestCase(TestCase):
self.assertEqual(self.instance.networks[0]["state"], "down")
def test_set_unset_qos(self):
self.assertEqual(len(self.instance.config_qos.keys()), 0)
self.assertFalse(self.instance.config_networks[0]["inbound"])
net_mac = self.instance.networks[0]["mac"]
response = self.client.post(
reverse("instances:set_qos", args=[self.instance.id]),
@ -1027,8 +1027,8 @@ class InstancesTestCase(TestCase):
)
self.assertEqual(response.status_code, 302)
del self.instance.config_qos
self.assertEqual(len(self.instance.config_qos.keys()), 1)
del self.instance.config_networks
self.assertEqual(self.instance.config_networks[0]["inbound"]["average"], "1")
response = self.client.post(
reverse("instances:unset_qos", args=[self.instance.id]),
@ -1039,8 +1039,8 @@ class InstancesTestCase(TestCase):
HTTP_REFERER=reverse("index"),
)
self.assertEqual(response.status_code, 302)
del self.instance.config_qos
self.assertEqual(len(self.instance.config_qos.keys()), 0)
del self.instance.config_networks
self.assertFalse(self.instance.config_networks[0]["inbound"])
# test on running instance
# self.instance.proxy.start()

View file

@ -1,17 +1,12 @@
{% load i18n %}
{% load bootstrap_icons %}
{% if request.user.is_superuser %}
<a href="#AddQos{{ id }}" type="button" class="btn btn-sm btn-success float-end" data-bs-toggle="modal" title="add qos">
<span aria-hidden="true">{% bs_icon 'qr-code' %} </span>
</a>
<!-- Modal pool -->
<div class="modal fade" id="AddQos{{ id }}" tabindex="-1" role="dialog" aria-labelledby="AddQosLabel{{ id }}"
aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{% trans "Add QoS for Network" %}</h5>
<h5 class="modal-title" id="AddQosLabel{{ id }}">{% trans "Add QoS for Network" %}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">

View file

@ -0,0 +1,11 @@
<?xml version="1.0" ?>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-ethernet" viewBox="0 0 16 16">
<path d="M14 13.5v-7a.5.5 0 0 0-.5-.5H12V4.5a.5.5 0 0 0-.5-.5h-1v-.5A.5.5 0 0 0 10 3H6a.5.5 0 0 0-.5.5V4h-1a.5.5 0 0 0-.5.5V6H2.5a.5.5 0 0 0-.5.5v7a.5.5 0 0 0 .5.5h11a.5.5 0 0 0 .5-.5M3.75 11h.5a.25.25 0 0 1 .25.25v1.5a.25.25 0 0 1-.25.25h-.5a.25.25 0 0 1-.25-.25v-1.5a.25.25 0 0 1 .25-.25m2 0h.5a.25.25 0 0 1 .25.25v1.5a.25.25 0 0 1-.25.25h-.5a.25.25 0 0 1-.25-.25v-1.5a.25.25 0 0 1 .25-.25m1.75.25a.25.25 0 0 1 .25-.25h.5a.25.25 0 0 1 .25.25v1.5a.25.25 0 0 1-.25.25h-.5a.25.25 0 0 1-.25-.25zM9.75 11h.5a.25.25 0 0 1 .25.25v1.5a.25.25 0 0 1-.25.25h-.5a.25.25 0 0 1-.25-.25v-1.5a.25.25 0 0 1 .25-.25m1.75.25a.25.25 0 0 1 .25-.25h.5a.25.25 0 0 1 .25.25v1.5a.25.25 0 0 1-.25.25h-.5a.25.25 0 0 1-.25-.25z"/>
<path d="M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2zM1 2a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1z"/>
</svg>

After

Width:  |  Height:  |  Size: 1 KiB

View file

@ -2883,48 +2883,6 @@ class wvmInstance(wvmConnect):
def set_memory(self, size, flags=0):
self.instance.setMemoryFlags(size, flags)
def get_all_qos(self, config=False):
qos_values = dict()
tree = etree.fromstring(self._XMLDesc(PERSISTENT_XML if config else 0))
qos = tree.xpath("/domain/devices/interface")
for q in qos:
bound_list = list()
mac = q.xpath("mac/@address")
band = q.find("bandwidth")
if band is not None:
in_qos = band.find("inbound")
if in_qos is not None:
in_av = in_qos.get("average")
in_peak = in_qos.get("peak")
in_burst = in_qos.get("burst")
in_floor = in_qos.get("floor")
bound_list.append(
{
"direction": "inbound",
"average": in_av,
"peak": in_peak,
"floor": in_floor,
"burst": in_burst,
}
)
out_qos = band.find("outbound")
if out_qos is not None:
out_av = out_qos.get("average")
out_peak = out_qos.get("peak")
out_burst = out_qos.get("burst")
bound_list.append(
{
"direction": "outbound",
"average": out_av,
"peak": out_peak,
"burst": out_burst,
}
)
qos_values[mac[0]] = bound_list
return qos_values
def set_qos(self, mac, direction, average, peak, burst):
if direction not in ("inbound", "outbound"):
raise ValueError("Direction must be inbound or outbound")

View file

@ -110,10 +110,8 @@ class DeviceEditsTestCase(unittest.TestCase):
p._defineXML.assert_called_once()
p.wvm.defineXML.assert_not_called()
def test_qos_is_read_from_either_definition_and_an_edit_keeps_the_floor(self):
def test_a_qos_edit_keeps_the_floor(self):
p = proxy()
self.assertEqual(p.get_all_qos(config=True)["52:54:10:00:00:01"][0]["floor"], "4")
self.assertEqual(p.get_all_qos(), {}) # the running VM has none
p.set_qos("52:54:10:00:00:01", "inbound", 10, 20, 30)
inbound = defined(p).find("devices/interface[@type='network']/bandwidth/inbound")
self.assertEqual(inbound.attrib, {"average": "10", "peak": "20", "burst": "30", "floor": "4"})