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

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

As the network and interface pages; plan reviewed with Codex.

- The list is one table with a search: each filter's chain, priority,
  number of rules, the filters it references, and how many VMs and
  filters use it directly; a row's menu clones it (one shared dialog,
  the copy's name filled in) or deletes it, Delete disabled and saying
  so while it is in use. Without the VMs (a host that fails to list
  them) the list is not shown: it would offer to delete filters in use.
- New filter is one dialog with the filter as XML in the Ace editor,
  from a starter filter, with what in, out and priority mean and a link
  to libvirt's format.
- A filter's page: a Filter card (UUID, chain, priority, the VMs that
  use it directly, linked, and the filters that reference it, linked);
  its references with their parameters (one to a filter gone from the
  host marked as missing, not linked), removed per row, added from a
  select of the filters it can reference; its rules with the action as
  a badge, the direction as to or from the VM, the priority (500 where
  none is given), every protocol element with its attributes, a rule
  of no protocol as any traffic of the chain, and when it does not
  track connections; Add rule as an XML dialog; the XML, collapsed,
  saved in place.
- The editors size themselves when shown and keep their label for
  screen readers; tables keep their hidden
  header text inside them, so a phone does not scroll the page
  sideways. add_nwf_rule.html goes (the dialog is on the page).
This commit is contained in:
catborise 2026-10-29 00:21:00 +03:00
parent bc2b70544a
commit 0014b5f240
7 changed files with 367 additions and 263 deletions

View file

@ -1,33 +0,0 @@
{% load i18n %}
{% load bootstrap_icons %}
{% if request.user.is_superuser %}
<button class="btn btn-success float-end" type="button" title="{% trans "Add New NWFilter Rule" %}" data-bs-toggle="modal" data-bs-target="#AddNWFilterRule">
<span aria-hidden="true">{% bs_icon 'plus' %} </span>
</button>
<!-- Modal Secret -->
<div class="modal fade" id="AddNWFilterRule" tabindex="-1" role="dialog" aria-labelledby="AddNWFilterRule" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{% trans "Add New NWFilter Rule" %}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<form method="post" role="form" aria-label="Add new NWFilter rule form">{% csrf_token %}
<div class="row">
<div class="col-12 xmlheight">
<input type="hidden" name="nwfilterrule_xml"/>
<textarea id="rule_editor"></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% trans "Close" %}</button>
<button type="submit" class="btn btn-primary" name="add_nwfilter_rule">{% trans "Add" %}</button>
</div>
</form>
</div> <!-- /.modal-content -->
</div>
</div> <!-- /.modal-dialog -->
</div><!-- /.modal -->
{% endif %}

View file

@ -1,33 +1,39 @@
{% load i18n %}
{% load bootstrap_icons %}
{% if request.user.is_superuser %}
<button type="button" class="btn btn-success mt-1 float-end" title="{% trans "Create New NWFilter" %}" data-bs-toggle="modal" data-bs-target="#AddNWFilter">
<span aria-hidden="true">{% bs_icon 'plus' %} </span>
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#AddNWFilter">
<span aria-hidden="true">{% bs_icon 'plus-lg' %}</span> {% trans "New filter" %}
</button>
<!-- Modal Secret -->
<div class="modal fade" id="AddNWFilter" tabindex="-1" role="dialog" aria-labelledby="AddNWFilter" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
{# the whole filter as XML: its rules, and references with their parameters #}
<div class="modal fade" id="AddNWFilter" tabindex="-1" aria-labelledby="AddNWFilterLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-fullscreen-sm-down">
<form method="post" class="modal-content">{% csrf_token %}
<div class="modal-header">
<h5 class="modal-title">{% trans "Create New NWFilter" %}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form method="post" role="form" aria-label="Create new NWFilter form">{% csrf_token %}
<div class="modal-body">
<div class="row">
<div class="col-12 xmlheight">
<input type="hidden" name="nwfilter_xml"/>
<textarea id="editor" name="from_xml"></textarea>
</div>
<div>
<h5 class="modal-title" id="AddNWFilterLabel">{% trans "New filter" %}</h5>
<div class="small text-body-secondary">{% blocktrans with host=compute.name %}On {{ host }}{% endblocktrans %}</div>
</div>
</div> <!-- /.modal-body -->
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% trans "Close" %}"></button>
</div>
<div class="modal-body">
<p class="small text-body-secondary" id="create_editor_help">
{% trans "Name it and give its rules and the filters it references; libvirt gives it a UUID. In a rule, in is traffic to the VM, out from it; a lower priority applies first." %}
<a href="https://libvirt.org/formatnwfilter.html" target="_blank" rel="noopener">{% trans "libvirt's filter format" %}</a>
</p>
<input type="hidden" name="nwfilter_xml">
<div class="xmlheight"><textarea id="create_editor" aria-label="{% trans "Filter XML" %}" aria-describedby="create_editor_help">&lt;filter name='web-in' chain='root'&gt;
&lt;filterref filter='clean-traffic'/&gt;
&lt;rule action='accept' direction='in' priority='500'&gt;
&lt;tcp dstportstart='443'/&gt;
&lt;/rule&gt;
&lt;/filter&gt;</textarea></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% trans "Close" %}</button>
<button type="submit" class="btn btn-primary" name="create_nwfilter">{% trans "Create" %}</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% trans "Cancel" %}</button>
<button type="submit" class="btn btn-primary" name="create_nwfilter">{% trans "Create filter" %}</button>
</div>
</form>
</div>
</div> <!-- /.modal-dialog -->
</div><!-- /.modal -->
</div>
</div>
{% endif %}

View file

@ -3,161 +3,210 @@
{% load static %}
{% load bootstrap_icons %}
{% block title %}{% trans "NWFilter" %}: {{ name }}{% endblock %}
{% block title %}{% trans "Network filter" %} - {{ name }}{% endblock %}
{% block page_heading %}{% trans "NWFilter" %}: {{ name }}{% endblock page_heading %}
{% block page_heading %}{{ compute.name }} - {% trans "Network filter" %}: {{ name }}{% endblock page_heading %}
{% block content %}
<div class="row">
<div class="col-lg-12">
{% include "computes/host_nav.html" with section="nwfilters" detail=True %}
</div>
{% include "computes/host_nav.html" with section="nwfilters" detail=True %}
{% if uuid %}
<div class="card mb-3">
<div class="card-header">{% trans "Filter" %}</div>
<ul class="list-group list-group-flush">
<li class="list-group-item py-1 d-flex justify-content-between align-items-baseline gap-3">
<span class="text-body-secondary">{% trans "UUID" %}</span><span class="font-monospace text-break text-end">{{ uuid }}</span>
</li>
<li class="list-group-item py-1 d-flex justify-content-between align-items-baseline gap-3">
<span class="text-body-secondary">{% trans "Chain" %}</span><span class="font-monospace">{{ chain|default:"–" }}</span>
</li>
<li class="list-group-item py-1 d-flex justify-content-between align-items-baseline gap-3">
<span class="text-body-secondary">{% trans "Priority" %}</span><span>{% if priority is not None %}{{ priority }}{% else %}–{% endif %}</span>
</li>
<li class="list-group-item py-1 d-flex justify-content-between align-items-baseline gap-3">
<span class="text-body-secondary">{% trans "Directly used by" %}</span>
<span class="text-end">{% for vm in vms %}{% if vm.id %}<a href="{% url 'instances:instance' vm.id %}">{{ vm.name }}</a>{% else %}{{ vm.name }}{% endif %}{% if not forloop.last %}, {% endif %}{% empty %}–{% endfor %}</span>
</li>
<li class="list-group-item py-1 d-flex justify-content-between align-items-baseline gap-3">
<span class="text-body-secondary">{% trans "Referenced by" %}</span>
<span class="text-end">{% for other in referenced_by %}<a href="{% url 'nwfilter' compute.id other %}">{{ other }}</a>{% if not forloop.last %}, {% endif %}{% empty %}–{% endfor %}</span>
</li>
</ul>
</div>
<dl class="ms-3 row">
<dt class="col-4">{% trans "UUID" %}:</dt>
<dd class="col-8">{{ uuid }}</dd>
<dt class="col-4">{% trans "Name" %}:</dt>
<dd class="col-8">{{ name }}</dd>
</dl>
<div class="row">
<div class="d-grid col-lg-12">
<button class="btn btn-light mb-3" 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 class="form" method="post" role="form">{% csrf_token %}
<div class="col-12 xmlheight">
<input type="hidden" name="edit_xml"/>
<textarea id="edit_editor">{{ xml }}</textarea>
</div>
<button type="submit" class="btn btn-primary float-end" name="edit_nwfilter">
{% trans "Edit" %}
</button>
</form>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-lg-12">
<h5 class="mb-3 me-auto">{% trans "Filter References" %}
<form class="float-end" method="post" role="form" aria-label="Add NWFilter form">{% csrf_token %}
<div class="input-group">
<label class="input-group-text" for="nwfilter_select">{% trans "Filter" %}</label>
<select id="nwfilter_select" name="nwfilters_select" class="form-select">
<option value="" selected>{% trans 'None' %}</option>
{% for nwf in ref_choices %}
<option value="{{ nwf }}">{{ nwf }}</option>
{% endfor %}
</select>
<button type="submit" class="btn btn-success float-end" name="add_nwfilter_ref" title="{% trans "Add" %}">
<span aria-hidden="true">{% bs_icon 'plus-circle-fill' %} </span>
</button>
</div>
<div class="card mb-3">
<div class="card-header d-flex flex-wrap align-items-center gap-2">
<span class="me-auto">{% trans "References" %}</span>
{% if ref_choices %}
<form method="post" class="d-flex gap-2">{% csrf_token %}
<select name="nwfilters_select" class="form-select form-select-sm w-auto" aria-label="{% trans "Filter to reference" %}" required>
{% for choice in ref_choices %}<option value="{{ choice }}">{{ choice }}</option>{% endfor %}
</select>
<button type="submit" class="btn btn-sm btn-primary text-nowrap" name="add_nwfilter_ref">{% bs_icon 'plus-lg' %} {% trans "Add" %}</button>
</form>
</h5>
{% endif %}
</div>
</div>
<div class="row">
<div class="col-12 col-sm-12">
<div class="table-responsive">
<table class="table table-hover mb-3 align-middle">
<thead>
<tr>
<th scope="col">#</th>
<th scope="col">{% trans "Reference" %}</th>
<th scope="col">{% trans "Action" %}</th>
</tr>
</thead>
<tbody>
{% for ref in refs %}
<tr>
<th scope="row">{{ forloop.counter }}</th>
<td>{{ ref.filter }}{% for param, value in ref.parameters %} <span class="font-monospace small">{{ param }}={{ value }}</span>{% endfor %}</td>
<td style="width:30px;">
<form action="" method="post" role="form" aria-label="Delete NWFilter form">{% csrf_token %}
<input type="hidden" name="index" value="{{ forloop.counter0 }}">
<input type="hidden" name="ref" value="{{ ref.text }}">
<button type="submit" class="btn btn-sm btn-danger" name="del_nwfilter_ref" title="{% trans "Delete" %}" data-confirm="{% blocktrans with ref=ref.filter %}Remove the reference to {{ ref }}? Its rules no longer apply through this filter.{% endblocktrans %}" onclick="return confirm(this.dataset.confirm)">
{% bs_icon 'trash' %}
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% include 'add_nwf_rule.html' %}
<h5 class="mb-3">{% trans "Rules" %}</h5>
<div class="table-responsive">
<table class="table table-hover align-middle">
{% if refs %}
<div class="table-responsive position-relative">
<table class="table table-hover align-middle mb-0">
<thead>
<tr>
<th scope="col">{% trans "Rule" %}</th>
<th scope="col" class="text-nowrap">{% trans "Action Type" %}</th>
<th scope="col">{% trans "Filter" %}</th>
<th scope="col">{% trans "Parameters" %}</th>
<th scope="col"><span class="visually-hidden">{% trans "Actions" %}</span></th>
</tr>
</thead>
<tbody>
{% for ref in refs %}
<tr>
<td class="text-nowrap">
{% if ref.filter in filter_names %}<a href="{% url 'nwfilter' compute.id ref.filter %}">{{ ref.filter }}</a>
{% else %}{{ ref.filter }} <span class="badge bg-danger-subtle text-danger-emphasis">{% trans "Missing" %}</span>{% endif %}
</td>
<td class="font-monospace small">{% for param, value in ref.parameters %}<div>{{ param }}={{ value }}</div>{% empty %}–{% endfor %}</td>
<td class="text-end">
<form method="post">{% csrf_token %}
<input type="hidden" name="index" value="{{ forloop.counter0 }}">
<input type="hidden" name="ref" value="{{ ref.text }}">
<button type="submit" class="btn btn-sm btn-outline-danger" name="del_nwfilter_ref" aria-label="{% blocktrans with ref=ref.filter %}Remove the reference to {{ ref }}{% endblocktrans %}"
data-confirm="{% blocktrans with ref=ref.filter %}Remove the reference to {{ ref }}? Its rules no longer apply through this filter.{% endblocktrans %}"
onclick="return confirm(this.dataset.confirm)">{% bs_icon 'trash' %}</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="card-body small text-body-secondary">{% trans "It references no other filter." %}</div>
{% endif %}
</div>
<div class="card mb-3">
<div class="card-header d-flex flex-wrap align-items-center gap-2">
<span class="me-auto">{% trans "Rules" %}</span>
<button type="button" class="btn btn-sm btn-primary" data-bs-toggle="modal" data-bs-target="#AddNWFilterRule">{% bs_icon 'plus-lg' %} {% trans "Add rule" %}</button>
</div>
{% if rules %}
<div class="table-responsive position-relative">
<table class="table table-hover align-middle mb-0">
<thead>
<tr>
<th scope="col">#</th>
<th scope="col">{% trans "Action" %}</th>
<th scope="col">{% trans "Direction" %}</th>
<th scope="col">{% trans "Priority" %}</th>
<th scope="col">{% trans "Statematch" %}</th>
<th scope="col">{% trans "Directives" %}</th>
<th scope="col" style="width:30px;">{% trans "Action" %}</th>
<th scope="col">{% trans "Matches" %}</th>
<th scope="col"><span class="visually-hidden">{% trans "Actions" %}</span></th>
</tr>
</thead>
<tbody>
{% for rule in rules %}
<tr>
<th scope="row">{{ forloop.counter }}</th>
<td>{{ rule.action }}</td>
<td>{{ rule.direction }}</td>
<td>{{ rule.priority }}</td>
<td>{{ rule.statematch }}</td>
<td class="font-monospace small">{% for protocol in rule.protocols %}<div>{{ protocol.name }}{% for attribute, value in protocol.attributes %} {{ attribute }}={{ value }}{% endfor %}</div>{% endfor %}</td>
<td>
<form action="" method="post" role="form" aria-label="Delete NWFilter rule">{% csrf_token %}
{% if rule.action == "accept" %}<span class="badge bg-success-subtle text-success-emphasis">{% trans "Accept" %}</span>
{% elif rule.action == "drop" or rule.action == "reject" %}<span class="badge bg-danger-subtle text-danger-emphasis">{{ rule.action|capfirst }}</span>
{% else %}<span class="badge text-bg-light border">{{ rule.action|capfirst }}</span>{% endif %}
</td>
<td class="text-nowrap">{% if rule.direction == "in" %}{% trans "To the VM" %}{% elif rule.direction == "out" %}{% trans "From the VM" %}{% elif rule.direction == "inout" %}{% trans "Both ways" %}{% else %}{{ rule.direction }}{% endif %}</td>
<td>{% if rule.priority is not None %}{{ rule.priority }}{% else %}500{% endif %}</td>
<td class="small">
{% for protocol in rule.protocols %}
<div><span class="badge text-bg-light border">{{ protocol.name }}</span>{% for attribute, value in protocol.attributes %} <span class="font-monospace">{{ attribute }}={{ value }}</span>{% endfor %}</div>
{% empty %}
<span class="text-body-secondary">{% trans "Any traffic of the chain" %}</span>
{% endfor %}
{% if rule.statematch == "false" or rule.statematch == "0" %}<div class="text-body-secondary">{% trans "Without connection tracking" %}</div>{% endif %}
</td>
<td class="text-end">
<form method="post">{% csrf_token %}
<input type="hidden" name="index" value="{{ forloop.counter0 }}">
<input type="hidden" name="rule" value="{{ rule.text }}">
<button type="submit" class="btn btn-sm btn-danger" name="del_nwfilter_rule" title="{% trans 'Delete' %}" data-confirm="{% trans "Delete this rule? Interfaces that use the filter no longer get it." %}" onclick="return confirm(this.dataset.confirm)">
{% bs_icon 'trash' %}
</button>
<button type="submit" class="btn btn-sm btn-outline-danger" name="del_nwfilter_rule" aria-label="{% blocktrans with number=forloop.counter %}Delete rule {{ number }}{% endblocktrans %}"
data-confirm="{% trans "Delete this rule? Interfaces that use the filter no longer get it." %}"
onclick="return confirm(this.dataset.confirm)">{% bs_icon 'trash' %}</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="card-body small text-body-secondary">{% trans "The filter has no rules of its own." %}</div>
{% endif %}
</div>
<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="#nwfilter-xml" aria-expanded="false" aria-controls="nwfilter-xml">
{% trans "XML definition" %}
</button>
</h2>
<div class="accordion-collapse collapse" id="nwfilter-xml">
<form method="post" class="accordion-body" aria-label="{% trans "Change the filter's XML" %}">{% csrf_token %}
<p class="small text-body-secondary">{% trans "A change applies at once, also to the running VMs that use the filter; it keeps the filter's name and UUID." %}</p>
<input type="hidden" name="edit_xml">
<div class="xmlheight mb-3"><textarea id="edit_editor" aria-label="{% trans "Filter XML" %}">{{ xml }}</textarea></div>
<div class="d-flex justify-content-end">
<button type="submit" class="btn btn-primary" name="edit_nwfilter">{% trans "Save" %}</button>
</div>
</form>
</div>
</div>
</section>
<div class="modal fade" id="AddNWFilterRule" tabindex="-1" aria-labelledby="AddNWFilterRuleLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-fullscreen-sm-down">
<form method="post" class="modal-content">{% csrf_token %}
<div class="modal-header">
<h5 class="modal-title" id="AddNWFilterRuleLabel">{% trans "Add rule" %}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% trans "Close" %}"></button>
</div>
<div class="modal-body">
<p class="small text-body-secondary" id="rule_editor_help">
{% trans "One rule: in is traffic to the VM, out from it; a lower priority applies first (500 if none is given)." %}
<a href="https://libvirt.org/formatnwfilter.html#supported-protocols" target="_blank" rel="noopener">{% trans "The protocols and their attributes" %}</a>
</p>
<input type="hidden" name="nwfilterrule_xml">
<div class="xmlheight"><textarea id="rule_editor" aria-label="{% trans "Rule XML" %}" aria-describedby="rule_editor_help">&lt;rule action='accept' direction='in' priority='500'&gt;
&lt;tcp dstportstart='443'/&gt;
&lt;/rule&gt;</textarea></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="add_nwfilter_rule">{% trans "Add rule" %}</button>
</div>
</form>
</div>
</div>
{% endif %}
{% endblock %}
{% block script %}
<script src="{% static 'js/ace/ace.js' %}"></script>
<script>
var editor = ace.edit("edit_editor");
{% if uuid %}
<script src="{% static 'js/ace/ace.js' %}"></script>
<script>
// each editor's text goes in its form's hidden field, the starter too if sent as it is,
// and it measures itself when its section or dialog is shown
function xmlEditor(textareaId, fieldName, shownBy, shownEvent) {
var textarea = document.getElementById(textareaId);
var form = textarea.closest("form");
var editor = ace.edit(textareaId);
editor.getSession().setMode("ace/mode/xml");
var edit_input = $('input[name="edit_xml"]');
edit_input.val(editor.getSession().getValue());
editor.getSession().on("change",function () {
edit_input.val(editor.getSession().getValue());
editor.setOption("textInputAriaLabel", textarea.getAttribute("aria-label"));
var field = form.elements[fieldName];
field.value = editor.getSession().getValue();
editor.getSession().on("change", function () {
field.value = editor.getSession().getValue();
});
var rule_editor = ace.edit("rule_editor");
rule_editor.getSession().setMode("ace/mode/xml");
var rule_input = $('input[name="nwfilterrule_xml"]');
rule_input.val(rule_editor.getSession().getValue());
rule_editor.getSession().on("change",function () {
rule_input.val(rule_editor.getSession().getValue());
});
</script>
document.getElementById(shownBy).addEventListener(shownEvent, () => editor.resize());
}
xmlEditor("edit_editor", "edit_xml", "nwfilter-xml", "shown.bs.collapse");
xmlEditor("rule_editor", "nwfilterrule_xml", "AddNWFilterRule", "shown.bs.modal");
</script>
{% endif %}
{% endblock %}

View file

@ -3,106 +3,126 @@
{% load static %}
{% load bootstrap_icons %}
{% block title %}{% trans "NWFilters" %} - {{ compute.name }}{% endblock %}
{% block title %}{% trans "Network filters" %} - {{ compute.name }}{% endblock %}
{% block page_heading %}{{ compute.name }} - {% trans "NWFilters" %}{% endblock page_heading %}
{% block page_heading %}{{ compute.name }} - {% trans "Network filters" %}{% endblock page_heading %}
{% block page_heading_extra %}
{% include 'create_nwfilter_block.html' %}
{% include 'search_block.html' %}
{% endblock page_heading_extra %}
{% block page_heading_extra %}{% include 'create_nwfilter_block.html' %}{% endblock page_heading_extra %}
{% block content %}
<div class="row">
<div class="col-lg-12">
{% include "computes/host_nav.html" with section="nwfilters" %}
</div>
</div>
{% include "computes/host_nav.html" with section="nwfilters" %}
<div class="row">
<div class="col-lg-12">
{% if nwfilters %}
<div class="table-responsive">
<table align-middle class="table" data-sortable>
{% if not nwfilters %}
<div class="alert alert-warning shadow-sm">
{% bs_icon 'exclamation-triangle' %} {% trans "This host has no network filters." %}
</div>
{% else %}
<div class="card mb-3">
<div class="card-header d-flex flex-wrap align-items-center gap-2">
<span class="me-auto">{% trans "Filters" %} <span class="badge text-bg-light border">{{ nwfilters|length }}</span></span>
<input id="filter" class="form-control form-control-sm w-auto" type="search" placeholder="{% trans "Search" %}" aria-label="{% trans "Search filters" %}">
</div>
<div class="table-responsive position-relative">
<table class="table table-hover align-middle mb-0" data-sortable>
<thead>
<tr>
<th scope="col" data-sort-method="number">#</th>
<th scope="col">{% trans "UUID" %}</th>
<th scope="col">{% trans "Name" %}</th>
<th scope="colgroup" data-sort-method="none" colspan="2">{% trans "Action" %}</th>
<th scope="col">{% trans "Chain" %}</th>
<th scope="col" data-sort-method="number">{% trans "Priority" %}</th>
<th scope="col" data-sort-method="number">{% trans "Rules" %}</th>
<th scope="col">{% trans "References" %}</th>
<th scope="col">{% trans "Directly used by" %}</th>
<th scope="col" data-sort-method="none"><span class="visually-hidden">{% trans "Actions" %}</span></th>
</tr>
</thead>
<tbody class="searchable">
{% for nwfilter in nwfilters %}
<tr>
<th scope="row">{{ forloop.counter }}</th>
<td><a class="link-primary" href="{% url 'nwfilter' compute.id nwfilter.name %} ">{{ nwfilter.uuid }}</a></td>
<td>{{ nwfilter.name }}</td>
<td>
<div class="modal fade" id="Clone{{ forloop.counter }}" tabindex="-1" role="dialog" aria-labelledby="addNwFilterLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{% trans "Clone NWFilter" %} <span class="text-danger">{{ nwfilter.name }}</span></h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form method="post" role="form" aria-label="Clone NWFilter form">{% csrf_token %}
<div class="modal-body">
<div class="row">
<label class="col-sm-3 col-form-label">{% trans "Name" %}</label>
<div class="col-sm-6">
<input type="text" class="form-control" name="cln_name" placeholder="{% trans "Name" %}" required pattern="[a-zA-Z0-9\.\-_]+">
<input type="hidden" name="nwfiltername" value="{{ nwfilter.name }}">
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% trans "Close" %}</button>
<button type="submit" class="btn btn-primary" name="cln_nwfilter">{% trans "Clone" %}</button>
</div>
</form>
</div> <!-- /.modal-content -->
</div> <!-- /.modal-dialog -->
</div> <!-- /.modal -->
<button class="btn btn-sm btn-secondary" data-bs-toggle="modal" data-bs-target="#Clone{{ forloop.counter }}" title="{% trans "Clone" %}">{% bs_icon 'files' %} </button>
</td>
<td>
<form action="" method="post" role="form" aria-label="Delete NWFilter form">{% csrf_token %}
<input type="hidden" name="nwfiltername" value="{{ nwfilter.name }}">
<button type="submit" class="btn btn-sm btn-secondary" name="del_nwfilter" title="{% trans "Delete" %}" data-confirm="{% blocktrans with name=nwfilter.name %}Delete filter {{ name }}? A filter a VM uses is not deleted.{% endblocktrans %}" onclick="return confirm(this.dataset.confirm)">
{% bs_icon 'trash' %}
</button>
</form>
</td>
</tr>
{% for nwf in nwfilters %}
<tr data-search="{{ nwf.name }} {{ nwf.chain|default:"" }}{% for ref in nwf.refs %} {{ ref }}{% endfor %}{% for vm in nwf.vms %} {{ vm }}{% endfor %}">
<td class="text-nowrap"><a href="{% url 'nwfilter' compute.id nwf.name %}">{{ nwf.name }}</a></td>
<td class="font-monospace text-nowrap">{{ nwf.chain|default:"–" }}</td>
<td>{% if nwf.priority is not None %}{{ nwf.priority }}{% else %}–{% endif %}</td>
<td>{{ nwf.rules }}</td>
<td class="small">{{ nwf.refs|join:", "|default:"–" }}</td>
<td class="small text-nowrap">
{% if nwf.vms or nwf.referenced_by %}
{% if nwf.vms %}{% blocktrans count counter=nwf.vms|length %}{{ counter }} VM{% plural %}{{ counter }} VMs{% endblocktrans %}{% endif %}{% if nwf.vms and nwf.referenced_by %}, {% endif %}
{% if nwf.referenced_by %}{% blocktrans count counter=nwf.referenced_by|length %}{{ counter }} filter{% plural %}{{ counter }} filters{% endblocktrans %}{% endif %}
{% else %}–{% endif %}
</td>
<td class="text-end">
<div class="btn-group">
<button type="button" class="btn btn-sm btn-outline-secondary" data-bs-toggle="dropdown" aria-expanded="false"
data-bs-popper-config='{"strategy": "fixed"}' aria-label="{% blocktrans with name=nwf.name %}Actions for filter {{ name }}{% endblocktrans %}">{% bs_icon 'three-dots' %}</button>
<ul class="dropdown-menu dropdown-menu-end shadow">
<li><button type="button" class="dropdown-item" data-bs-toggle="modal" data-bs-target="#CloneNWFilter" data-name="{{ nwf.name }}">{% bs_icon 'files' %} {% trans "Clone" %}</button></li>
<li><hr class="dropdown-divider"></li>
<li><form method="post">{% csrf_token %}
<input type="hidden" name="nwfiltername" value="{{ nwf.name }}">
{# a filter in use is not deleted: a VM using it, or a filter referencing it, would not start #}
<button type="submit" class="dropdown-item text-danger" name="del_nwfilter"{% if nwf.vms or nwf.referenced_by %} disabled{% endif %}
data-confirm="{% blocktrans with name=nwf.name %}Delete filter {{ name }}?{% endblocktrans %}"
onclick="return confirm(this.dataset.confirm)">{% bs_icon 'trash' %} {% if nwf.vms or nwf.referenced_by %}{% trans "Delete (in use)" %}{% else %}{% trans "Delete filter" %}{% endif %}</button>
</form></li>
</ul>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="row">
<div class="col-lg-12">
<div class="alert alert-warning shadow-sm">
{% bs_icon 'exclamation-triangle' %} <strong>{% trans "Warning" %}:</strong> {% trans "Hypervisor doesn't have any NWFilters" %}
</div>
</div>
<div role="status">
<div id="no-match" class="text-center text-body-secondary py-3" hidden>{% trans "No filter matches the search." %}</div>
</div>
{% endif %}
</div>
</div>
{# one dialog for every filter's clone: its row's Clone fills in the filter #}
<div class="modal fade" id="CloneNWFilter" tabindex="-1" aria-labelledby="CloneNWFilterLabel" aria-hidden="true">
<div class="modal-dialog">
<form method="post" class="modal-content">{% csrf_token %}
<input type="hidden" name="nwfiltername">
<div class="modal-header">
<h5 class="modal-title" id="CloneNWFilterLabel">{% trans "Clone filter" %} <span class="font-monospace" id="clone_source"></span></h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% trans "Close" %}"></button>
</div>
<div class="modal-body">
<label for="clone_name" class="form-label">{% trans "Name of the copy" %}</label>
<input id="clone_name" name="cln_name" class="form-control font-monospace" required pattern="[A-Za-z0-9._\-]+" aria-describedby="clone_name_help">
<div class="form-text" id="clone_name_help">{% trans "Letters, digits, dots, dashes and underscores. The copy has the filter's rules and references." %}</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="cln_nwfilter">{% trans "Clone" %}</button>
</div>
</form>
</div>
</div>
{% endif %}
{% endblock %}
{% block script %}
{% if nwfilters %}
<script src="{% static 'js/table_filter.js' %}"></script>
<script>
document.getElementById("CloneNWFilter").addEventListener("show.bs.modal", function (event) {
var name = event.relatedTarget.dataset.name;
this.querySelector("form").elements.nwfiltername.value = name;
document.getElementById("clone_source").textContent = name;
document.getElementById("clone_name").value = name + "-copy";
});
</script>
{% endif %}
<script src="{% static 'js/ace/ace.js' %}"></script>
<script>
var editor = ace.edit("editor");
var editor = ace.edit("create_editor");
editor.getSession().setMode("ace/mode/xml");
editor.setOption("textInputAriaLabel", "{{ _('Filter XML')|escapejs }}");
var input = $('input[name="nwfilter_xml"]');
input.val(editor.getSession().getValue());
editor.getSession().on("change",function () {
input.val(editor.getSession().getValue()); // the starter too, if sent as it is
editor.getSession().on("change", function () {
input.val(editor.getSession().getValue());
})
});
// the editor measures itself when its dialog is shown
document.getElementById("AddNWFilter").addEventListener("shown.bs.modal", () => editor.resize());
</script>
{% endblock %}

View file

@ -4,13 +4,14 @@ refused). A definition libvirt refuses is reported, not undone by an undefine
and another define. libvirt's test driver has no filters, so the connection
is a mock."""
from unittest.mock import patch
from unittest.mock import MagicMock, patch
from computes.models import Compute
from django.contrib.auth import get_user_model
from django.contrib.messages import get_messages
from django.test import TestCase
from django.urls import reverse
from instances.models import Instance
from libvirt import libvirtError
from lxml import etree, html
from vrtManager.nwfilters import element_text
@ -133,3 +134,50 @@ class RuleDeleteTests(TestCase):
response = self.delete(index, rule)
self.assertIn("reload the page", " ".join(str(m) for m in get_messages(response.wsgi_request)))
self.wvm.nwfilterDefineXML.assert_not_called()
class PageTests(TestCase):
"""The list says what uses each filter, and does not offer to delete one in
use; a filter's page links the VMs that use it and shows its references'
parameters."""
def setUp(self):
patcher = patch("vrtManager.connection.connection_manager.get_connection")
self.wvm = patcher.start().return_value
self.addCleanup(patcher.stop)
nwfilter = self.wvm.nwfilterLookupByName.return_value
nwfilter.XMLDesc.return_value = XML.replace("<filterref filter='clean-traffic'/>",
"<filterref filter='clean-traffic'><parameter name='IP' value='192.0.2.10'/></filterref>")
nwfilter.name.return_value = "web"
nwfilter.UUIDString.return_value = UUID
self.wvm.listAllNWFilters.return_value = [nwfilter]
self.wvm.listNWFilters.return_value = ["web", "clean-traffic"]
vm = MagicMock(**{"name.return_value": "web-1", "isActive.return_value": False, "XMLDesc.return_value":
"<domain><devices><interface type='network'><filterref filter='web'/></interface></devices></domain>"})
self.wvm.listAllDomains.return_value = [vm]
self.compute = Compute.objects.create(name="nwf", hostname="localhost", login="", password="", type=4)
self.client.force_login(get_user_model().objects.create_superuser("nwf", "nwf@example.com", "pw"))
def test_a_filter_in_use_is_not_offered_for_delete(self):
page = html.fromstring(self.client.get(reverse("nwfilters", args=[self.compute.id])).content)
[delete] = page.xpath("//button[@name='del_nwfilter']")
self.assertIsNotNone(delete.get("disabled"))
self.assertIn("1 VM", page.xpath("//tbody/tr/td[6]")[0].text_content())
def test_the_filter_page_links_its_vms_and_shows_parameters(self):
instance = Instance.objects.create(compute=self.compute, name="web-1", uuid="6d1f7a10-0000-4000-8000-00000000a001")
page = html.fromstring(self.client.get(reverse("nwfilter", args=[self.compute.id, "web"])).content)
self.assertEqual(page.xpath("//a[text()='web-1']/@href"), [reverse("instances:instance", args=[instance.id])])
self.assertIn("IP=192.0.2.10", page.text_content())
def test_without_the_vms_the_list_is_not_shown(self):
# it would offer to delete filters in use
self.wvm.listAllDomains.side_effect = libvirtError("connection reset")
page = html.fromstring(self.client.get(reverse("nwfilters", args=[self.compute.id])).content)
self.assertEqual(page.xpath("//button[@name='del_nwfilter']"), [])
def test_a_reference_to_a_filter_gone_is_not_a_link(self):
self.wvm.listNWFilters.return_value = ["web"]
page = html.fromstring(self.client.get(reverse("nwfilter", args=[self.compute.id, "web"])).content)
self.assertEqual(page.xpath("//a[text()='clean-traffic']"), [])
self.assertIn("Missing", page.xpath("//td[contains(., 'clean-traffic')]")[0].text_content())

View file

@ -4,6 +4,7 @@ from django.contrib import messages
from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404, render
from django.urls import reverse
from instances.models import Instance
from libvirt import libvirtError
from logs.audit import audit
from vrtManager import util
@ -39,7 +40,11 @@ def nwfilters(request, compute_id):
conn.delete_nwfilter(name)
return HttpResponseRedirect(request.get_full_path())
# the VMs first: a list without them would offer to delete filters in use
users = conn.get_users()
nwfilters_all = conn.get_nwfilters_info()
for nwf in nwfilters_all:
nwf["vms"] = users.get(nwf["name"], [])
except libvirtError as lib_err: # OperationError too: a name in use, a filter in use
messages.error(request, lib_err)
if request.method == "POST": # a host not reached as well: nothing was changed
@ -84,9 +89,16 @@ def nwfilter(request, compute_id, nwfltr):
name = nwfilter.get_name()
uuid = nwfilter.get_uuid()
xml = nwfilter.get_xml()
chain = nwfilter.tree.get("chain")
priority = nwfilter.tree.get("priority")
rules = nwfilter.get_rules()
refs = nwfilter.get_refs()
ref_choices = nwfilter.get_ref_choices()
filter_names = nwfilter.get_nwfilters()
referenced_by = [nwf["name"] for nwf in nwfilter.get_nwfilters_info() if name in nwf["refs"]]
vm_names = nwfilter.get_users().get(name, [])
vm_ids = dict(Instance.objects.filter(compute=compute, name__in=vm_names).values_list("name", "id"))
vms = [{"name": vm_name, "id": vm_ids.get(vm_name)} for vm_name in vm_names]
except libvirtError as lib_err:
messages.error(request, lib_err)
if request.method == "POST": # a filter gone meanwhile, a host not reached: nothing was changed

View file

@ -34,10 +34,12 @@ def filter_info(nwfilter, tree):
class wvmNWFilters(wvmConnect):
def get_nwfilters_info(self):
"""Every filter of the host (filter_info) by name: one listing, one XML
read per filter."""
"""Every filter of the host (filter_info), with the filters that
reference it, by name: one listing, one XML read per filter."""
filters = [filter_info(nwfilter, etree.fromstring(nwfilter.XMLDesc(0)))
for nwfilter in self.wvm.listAllNWFilters(0)]
for nwfilter in filters:
nwfilter["referenced_by"] = [other["name"] for other in filters if nwfilter["name"] in other["refs"]]
return sorted(filters, key=lambda nwfilter: nwfilter["name"])
def get_users(self):
@ -101,14 +103,14 @@ class wvmNWFilters(wvmConnect):
self.get_nwfilter(name).undefine()
class wvmNWFilter(wvmConnect):
class wvmNWFilter(wvmNWFilters):
"""A filter of the host. A page reads its XML once (tree); a change is
the filter defined again with its UUID, which libvirt does in place, also
while a VM uses it, and refuses for XML it cannot parse or apply to the
running VMs' interfaces."""
def __init__(self, host, login, passwd, conn, nwfiltername):
wvmConnect.__init__(self, host, login, passwd, conn)
wvmNWFilters.__init__(self, host, login, passwd, conn)
self.nwfilter = self.get_nwfilter(nwfiltername)
@cached_property