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

feat(storages): the storage pages on Bootstrap's tables, cards, badges and menus

- The pool list is a card per pool, two to a row, as the host
  Overview's Hardware and Software cards: its name with Active or
  Stopped and Autostart badges in the header, then its type, its path
  (an RBD pool's Ceph pool), its volume count and its space as the
  Overview shows it (computes/pool_bar.html, without the name again);
  a stopped pool's space is unavailable, not zero.
  wvmStorages.get_storages_info gives the path, autostart and percent
  used, which computes.views.host_pools computed for itself.
- A pool's page has a Space card (percent, used of size, of what: the
  filesystem of its path, its volume group or the Ceph cluster, which
  other pools may share; free) and a Pool card (state, autostart,
  type, path) instead of a two-column list mixing facts and forms.
  Start or Stop is its heading's button; autostart and deleting a
  stopped pool are in its menu; the confirmations stay.
- The volume table drops the row number; a volume's capacity and what
  it has allocated share a column hidden on a phone; its format is a
  badge; its actions are one group, Clone and a menu with Delete,
  named for the volume.
- "New pool", "New volume" and "Upload ISO" are labelled buttons
  instead of bare green pluses; their dialogs' titles name them.
- The New pool dialog is one form (design reviewed with Codex): the
  pool's type as radio cards, as the VM create page chooses its disk
  source (their style moves to webvirtcloud.css), one name, and only
  the chosen type's fields, the others hidden and disabled.
  - A directory's or a share's mount path follows the name
    (/var/lib/libvirt/<name>) until it is edited: no default path of
    another pool any more.
  - ISO images is a directory preset named iso; when the host has an
    iso pool, a card links to it instead.
  - Ceph offers the host's Ceph secrets by their names, not every
    secret by UUID; without one it says so and links the Secrets page.
  - LVM asks to agree that the device is erased, the server too.
  - AddStgPool checks each type's fields, a name or a path another
    pool has, an RBD secret that is not one of the host's Ceph secrets,
    and the names "." and "..". A refused pool, or one libvirt refuses,
    opens the dialog again with what was entered, the reasons beside
    the fields and focus on the first; the audit row for a taken name
    stays.
This commit is contained in:
catborise 2026-10-28 15:30:34 +03:00
parent fa5a8c4d6e
commit 3aa198304a
22 changed files with 729 additions and 470 deletions

View file

@ -1,5 +1,6 @@
{% load i18n %}
<div class="small">{{ pool.name }}</div>
{# a pool's space; hide_name: the page names the pool next to it #}
{% if not hide_name %}<div class="small">{{ pool.name }}</div>{% endif %}
<div class="progress my-1" role="progressbar" style="--bs-progress-height: 0.3rem;"
aria-label="{% blocktrans with name=pool.name %}{{ name }} used{% endblocktrans %}" aria-valuenow="{{ pool.used_percent|floatformat:0 }}" aria-valuemin="0" aria-valuemax="100">
<div class="progress-bar {% if pool.used_percent > 90 %}bg-danger{% elif pool.used_percent > 75 %}bg-warning{% endif %}" style="width: {{ pool.used_percent|stringformat:".1f" }}%"></div>

View file

@ -18,7 +18,8 @@ GIVEN = {"cpus": 8, "memory": 32 * GIB, "vcpus": {"all": 12, "running": 6},
def pool(name, size_gib, free_gib, status=1):
return {"name": name, "status": status, "size": size_gib * GIB, "free": free_gib * GIB}
return {"name": name, "status": status, "size": size_gib * GIB, "free": free_gib * GIB,
"used_percent": (size_gib - free_gib) * 100 / size_gib}
def text(page, xpath):

View file

@ -64,8 +64,6 @@ def host_pools(compute):
except libvirtError:
return None
pools.sort(key=lambda pool: (pool["name"] != "default", pool["name"]))
for pool in pools:
pool["used_percent"] = (pool["size"] - pool["free"]) * 100 / pool["size"] if pool["size"] else 0
return {"pool": pools[0] if pools else None, "more_pools": pools[1:]}

View file

@ -13,10 +13,6 @@
<style>
/* the chosen images and networks are listed below the pickers, not in them */
#image-control + .ts-wrapper .ts-control > .item, #network-control + .ts-wrapper .ts-control > .item { display: none; }
.source-option { cursor: pointer; }
/* the chosen card by its border only: the Bootswatch dark themes keep light "subtle" backgrounds */
.source-option:has(input:checked) { border-color: var(--bs-primary) !important; box-shadow: inset 0 0 0 1px var(--bs-primary); }
.source-option:has(input:focus-visible) { outline: 2px solid var(--bs-primary); outline-offset: 2px; }
</style>
{% endblock %}

View file

@ -65,6 +65,23 @@ th[aria-sort="ascending"]:not([data-sort-method="none"])::after {
}
}
/* a choice shown as a card around its radio button (the VM's disk source,
a new pool's type): the chosen card by its border only, as the
Bootswatch dark themes keep light "subtle" backgrounds */
.source-option {
cursor: pointer;
}
.source-option:has(input:checked) {
border-color: var(--bs-primary) !important;
box-shadow: inset 0 0 0 1px var(--bs-primary);
}
.source-option:has(input:focus-visible) {
outline: 2px solid var(--bs-primary);
outline-offset: 2px;
}
.host-collapsed tr[host] {
display: none;
}

View file

@ -0,0 +1,8 @@
<?xml version="1.0" ?>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-toggle-off" viewBox="0 0 16 16">
<path d="M11 4a4 4 0 0 1 0 8H8a4.992 4.992 0 0 0 2-4 4.992 4.992 0 0 0-2-4zm-6 8a4 4 0 1 1 0-8 4 4 0 0 1 0 8M0 8a5 5 0 0 0 5 5h6a5 5 0 0 0 0-10H5a5 5 0 0 0-5 5"/>
</svg>

After

Width:  |  Height:  |  Size: 331 B

View file

@ -0,0 +1,8 @@
<?xml version="1.0" ?>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-toggle-on" viewBox="0 0 16 16">
<path d="M5 3a5 5 0 0 0 0 10h6a5 5 0 0 0 0-10zm6 9a4 4 0 1 1 0-8 4 4 0 0 1 0 8"/>
</svg>

After

Width:  |  Height:  |  Size: 249 B

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-upload" viewBox="0 0 16 16">
<path d="M.5 9.9a.5.5 0 0 1 .5.5v2.5a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-2.5a.5.5 0 0 1 1 0v2.5a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2v-2.5a.5.5 0 0 1 .5-.5"/>
<path d="M7.646 1.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1-.708.708L8.5 2.707V11.5a.5.5 0 0 1-1 0V2.707L5.354 4.854a.5.5 0 1 1-.708-.708z"/>
</svg>

After

Width:  |  Height:  |  Size: 454 B

View file

@ -11,61 +11,79 @@ POOL_PATH = re.compile(r"(?!-)(?:[/0-9a-zA-Z_.:]|(?<!/)-)+")
class AddStgPool(forms.Form):
"""A new pool of the host. pools: the host's pools (get_storages_info),
whose names and paths a new one may not take; ceph_secrets: the UUIDs of
its Ceph secrets, the only ones an RBD pool can use."""
# the fields each type needs, besides the name
NEEDS = {
"dir": ("target",),
"logical": ("source", "erase_device"),
"rbd": ("ceph_host", "ceph_pool", "ceph_user", "secret"),
"netfs": ("netfs_host", "source", "target"),
}
name = forms.CharField(
error_messages={"required": _("No pool name has been entered")}, max_length=20
)
stg_type = forms.CharField(max_length=10)
target = forms.CharField(
error_messages={"required": _("No path has been entered")},
max_length=100,
required=False,
stg_type = forms.ChoiceField(choices=[(t, t) for t in NEEDS])
target = forms.CharField(label=_("Path"), max_length=100, required=False)
source = forms.CharField(label=_("Device or remote path"), max_length=100, required=False)
erase_device = forms.BooleanField(label=_("Erase the device"), required=False)
ceph_user = forms.CharField(label=_("Ceph user"), required=False)
ceph_host = forms.CharField(label=_("Ceph monitor"), required=False)
ceph_pool = forms.CharField(label=_("Ceph pool"), required=False)
secret = forms.CharField(label=_("Ceph secret"), required=False)
netfs_host = forms.CharField(label=_("Server"), required=False)
source_format = forms.ChoiceField(
choices=[("auto", "auto"), ("nfs", "nfs"), ("glusterfs", "glusterfs"), ("cifs", "cifs")], required=False
)
source = forms.CharField(max_length=100, required=False)
ceph_user = forms.CharField(required=False)
ceph_host = forms.CharField(required=False)
ceph_pool = forms.CharField(required=False)
secret = forms.CharField(required=False)
netfs_host = forms.CharField(required=False)
source_format = forms.CharField(required=False)
def __init__(self, *args, pools=(), ceph_secrets=(), **kwargs):
super().__init__(*args, **kwargs)
self.pools = pools
self.ceph_secrets = ceph_secrets
def clean_name(self):
name = self.cleaned_data["name"]
have_symbol = re.match("^[a-zA-Z0-9._-]+$", name)
if not have_symbol:
if not re.fullmatch("[a-zA-Z0-9._-]+", name) or name in (".", ".."):
raise forms.ValidationError(
_("The pool name must not contain any special characters")
)
elif len(name) > 20:
raise forms.ValidationError(
_("The pool name must not exceed 20 characters")
)
if any(pool["name"] == name for pool in self.pools):
raise forms.ValidationError(_("This host has a pool of that name"), code="taken")
return name
def clean_target(self):
storage_type = self.cleaned_data["stg_type"]
target = self.cleaned_data["target"]
have_symbol = POOL_PATH.fullmatch(target)
if storage_type in ["dir", "netfs"] and not have_symbol:
if target and not POOL_PATH.fullmatch(target):
raise forms.ValidationError(
_("The target must not contain any special characters")
)
if storage_type in ["dir", "netfs"] and not target:
raise forms.ValidationError(_("No path has been entered"))
# an RBD pool's path is its Ceph pool, not a directory
used = next((pool["name"] for pool in self.pools
if pool["type"] != "rbd" and pool["path"] and pool["path"].rstrip("/") == target.rstrip("/")), None)
if target and used:
raise forms.ValidationError(_("Pool %(pool)s uses this path") % {"pool": used})
return target
def clean_source(self):
storage_type = self.cleaned_data["stg_type"]
source = self.cleaned_data["source"]
have_symbol = POOL_PATH.fullmatch(source)
if storage_type in ["logical", "netfs"]:
if not source:
raise forms.ValidationError(_("No device or path has been entered"))
if not have_symbol:
raise forms.ValidationError(
_("The disk source must not contain any special characters")
)
if source and not POOL_PATH.fullmatch(source):
raise forms.ValidationError(
_("The disk source must not contain any special characters")
)
return source
def clean(self):
data = super().clean()
for field in self.NEEDS.get(data.get("stg_type"), ()):
if not data.get(field) and field not in self.errors:
self.add_error(field, _("This field is required."))
if data.get("stg_type") == "rbd" and data.get("secret") and data["secret"] not in self.ceph_secrets:
self.add_error("secret", _("Choose one of the host's Ceph secrets"))
return data
class CreateVolumeForm(forms.Form):
name = forms.CharField(max_length=120)

View file

@ -1,258 +1,248 @@
{% load i18n %}
{% load bootstrap_icons %}
{% load form_fields %}
{% if request.user.is_superuser %}
<a href="#AddStgPool" type="button" class="btn btn-success float-end" title="{% trans "Create Storage Pool" %}" data-bs-toggle="modal">
<span aria-hidden="true">{% bs_icon 'plus-circle-fill' %} </span>
</a>
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#AddStgPool">
<span aria-hidden="true">{% bs_icon 'plus-lg' %}</span> {% trans "New pool" %}
</button>
<!-- Modal Storage pool -->
<div class="modal fade" id="AddStgPool" tabindex="-1" role="dialog" aria-labelledby="AddStgPoolLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
{# one form: a card per pool type, the fields of the chosen one (the others hidden and disabled, so not sent) #}
<div class="modal fade" id="AddStgPool" tabindex="-1" aria-labelledby="AddStgPoolLabel" aria-hidden="true"
{% if form.errors %}data-open{% endif %}>
<div class="modal-dialog modal-lg modal-dialog-scrollable modal-fullscreen-sm-down">
<form method="post" class="modal-content" id="pool-form">{% csrf_token %}
<input type="hidden" name="stg_type" value="{{ form.stg_type.value|default:"dir" }}">
<div class="modal-header">
<h5 class="modal-title">{% trans "Create Storage Pool" %}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
<div>
<h5 class="modal-title" id="AddStgPoolLabel">{% trans "New storage pool" %}</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 class="modal-body">
<ul class="nav nav-tabs" aria-label="Storage create types">
<li class="nav-item" role="presentation">
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#dirtab" type="button" role="tab" aria-controls="dirtab" aria-selected="true">
{% trans "DIR" %}
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#lvmtab" type="button" role="tab" aria-controls="lvmtab" aria-selected="false">
{% trans "LVM" %}
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#cephtab" type="button" role="tab" aria-controls="cephtab" aria-selected="false">
{% trans "CEPH" %}
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#netfstab" type="button" role="tab" aria-controls="netfstab" aria-selected="false">
{% trans "NETFS" %}
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#isotab" type="button" role="tab" aria-controls="isotab" aria-selected="false">
{% trans "ISO" %}
</button>
</li>
</ul>
<div class="tab-content">
<div class="tab-pane active" id="dirtab">
<form method="post" action="" role="form" aria-label="Create storage with pool dir path form">{% csrf_token %}
<div class="modal-body">
<div class="row">
<label for="stg_dir_stg_type" class="col-sm-3 col-form-label">{% trans "Type" %}</label>
<div class="col-sm-6">
<input id="stg_dir_stg_type" type="text" class="form-control" name="stg_type" value="dir" disabled>
<input type="hidden" name="stg_type" value="dir">
</div>
</div>
<div class="row">
<label for="stg_dir_name" class="col-sm-3 col-form-label">{% trans "Name" %}</label>
<div class="col-sm-6">
<input id="stg_dir_name" type="text" class="form-control" name="name" placeholder="{% trans "default" %}" maxlength="20" required pattern="[a-zA-Z0-9\.\-_]+">
</div>
</div>
<div class="row">
<label for="stg_dir_target" class="col-sm-3 col-form-label">{% trans "Path" %}</label>
<div class="col-sm-6">
<input id="stg_dir_target" type="text" name="target" class="form-control" value="/var/lib/libvirt/images" required>
</div>
{% include "instances/form_errors.html" %}
<fieldset class="mb-3">
<legend class="form-label fs-6">{% trans "Storage type" %}</legend>
<div class="row row-cols-1 row-cols-sm-2 g-2">
{% trans "Directory" as title %}{% trans "Image files in a directory of the host." as help %}
{% include "storages/pool_kind.html" with kind="dir" %}
{% 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" %}
{% 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" %}
{% 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" %}
{% if iso_pool %}
{# the upload button is on the page of the pool named iso only #}
<div class="col">
<div class="border rounded p-2 h-100">
<div class="d-flex flex-wrap align-items-center gap-2">
<span class="fw-semibold">{% trans "ISO images" %}</span>
<span class="badge text-bg-light border">{% trans "Already on this host" %}</span>
</div>
<div class="small text-body-secondary font-monospace text-break">{{ iso_pool.path }}</div>
<a class="small" href="{% url 'storage' compute.id 'iso' %}">{% trans "Open the ISO pool" %} →</a>
</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">{% trans "Create" %}</button>
</div>
</form>
</div>
<div class="tab-pane" id="lvmtab">
<form method="post" action="" role="form" aria-label="Create storage pool with LVM pool">{% csrf_token %}
<div class="modal-body">
<div class="row">
<label for="stg_logical_stg_type" class="col-sm-3 col-form-label">{% trans "Type" %}</label>
<div class="col-sm-6">
<input id="stg_logical_stg_type" type="text" class="form-control" name="stg_type" value="logical" disabled>
<input type="hidden" name="stg_type" value="logical">
</div>
</div>
<div class="row">
<label for="stg_logical_name" class="col-sm-3 col-form-label">{% trans "Name" %}</label>
<div class="col-sm-6">
<input id="stg_logical_name" type="text" class="form-control" name="name" placeholder="{% trans "default" %}" maxlength="20" required pattern="[a-zA-Z0-9\.\-_]+">
</div>
</div>
<div class="row">
<label for="stg_logical_source" class="col-sm-3 col-form-label">{% trans "Device" %}</label>
<div class="col-sm-6">
<input id="stg_logical_source" type="text" class="form-control" name="source" value="/dev/sdb" required>
</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="create">{% trans "Create" %}</button>
</div>
</form>
</div>
<div class="tab-pane" id="cephtab">
<form method="post" action="" role="form" aria-label="Create storage pool with ceph form">{% csrf_token %}
<div class="modal-body">
<div class="row">
<label for="stg_rbd_stg_type" class="col-sm-3 col-form-label">{% trans "Type" %}</label>
<div class="col-sm-6">
<input id="stg_rbd_stg_type" type="text" class="form-control" name="stg_type" value="rbd" disabled>
<input type="hidden" name="stg_type" value="rbd">
</div>
</div>
<div class="row">
<label for="stg_rbd_name" class="col-sm-3 col-form-label">{% trans "Name" %}</label>
<div class="col-sm-6">
<input id="stg_rbd_name" type="text" class="form-control" name="name" placeholder="rbdpool" maxlength="20" required pattern="[a-zA-Z0-9\.\-_]+">
</div>
</div>
<div class="row">
<label for="stg_rbd_ceph_user" class="col-sm-3 col-form-label">{% trans "Ceph User" %}</label>
<div class="col-sm-6">
<input id="stg_rbd_ceph_user" type="text" class="form-control" name="ceph_user" placeholder="libvirt" required pattern="[a-zA-Z0-9\.\-_]+">
</div>
</div>
<div class="row">
<label for="stg_rbd_ceph_pool" class="col-sm-3 col-form-label">{% trans "Ceph Pool" %}</label>
<div class="col-sm-6">
<input id="stg_rbd_ceph_pool" type="text" class="form-control" name="ceph_pool" placeholder="libvirt-pool" required pattern="[a-zA-Z0-9\.\-_]+">
</div>
</div>
<div class="row">
<label for="stg_rbd_ceph_host" class="col-sm-3 col-form-label">{% trans "Ceph Host" %}</label>
<div class="col-sm-6">
<input id="stg_rbd_ceph_host" type="text" class="form-control" name="ceph_host" placeholder="mon0.ceph.host" maxlength="253" required pattern="[a-zA-Z0-9\.\-_]+">
</div>
</div>
<div class="row">
<label for="stg_rbd_secret" class="col-sm-3 col-form-label">{% trans "Secrets" %}</label>
<div class="col-sm-6">
<select id="stg_rbd_secret" name="secret" class="form-control">
{% if secrets %}
{% for secret in secrets %}
<option value="{{ secret }}">{{ secret }}</option>
{% endfor %}
{% else %}
<option value="None">{% trans "None" %}</option>
{% endif %}
</select>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
{% trans "Close" %}
</button>
{% if secrets %}
<button type="submit" class="btn btn-primary" name="create">
{% trans "Create" %}
</button>
{% else %}
<button class="btn btn-primary disabled" name="create">
{% trans "Create" %}
</button>
{% endif %}
</div>
</form>
</div>
<div class="tab-pane" id="netfstab">
<form method="post" action="" role="form" aria-label="Create storage pool with net filesystem form">{% csrf_token %}
<div class="modal-body">
<div class="row">
<label for="stg_netfs_stg_type" class="col-sm-3 col-form-label">{% trans "Type" %}</label>
<div class="col-sm-6">
<input id="stg_netfs_stg_type" type="text" class="form-control" name="stg_type" value="netfs" disabled>
<input type="hidden" name="stg_type" value="netfs">
</div>
</div>
<div class="row">
<label for="stg_netfs_name" class="col-sm-3 col-form-label">{% trans "Name" %}</label>
<div class="col-sm-6">
<input id="stg_netfs_name" type="text" class="form-control" name="name" placeholder="netfspool" maxlength="20" required pattern="[a-zA-Z0-9\.\-_]+">
</div>
</div>
<div class="row">
<label for="stg_netfs_netfs_host" class="col-sm-3 col-form-label">{% trans "Hostname" %}</label>
<div class="col-sm-6">
<input id="stg_netfs_netfs_host" type="text" class="form-control" name="netfs_host" placeholder="nfs.example.com" maxlength="253" required pattern="[a-zA-Z0-9\.\-_]+">
</div>
</div>
<div class="row">
<label for="stg_netfs_source" class="col-sm-3 col-form-label">{% trans "Remote Path" %}</label>
<div class="col-sm-6">
<input id="stg_netfs_source" type="text" class="form-control" name="source" placeholder="/srv/storage/" maxlength="100" required>
</div>
</div>
<div class="row">
<label for="source_format" class="col-sm-3 col-form-label">{% trans "Format" %}</label>
<div class="col-sm-6">
<select name="source_format" class="form-control" id="source_format">
<option value="auto">{% trans "auto" %}</option>
<option value="nfs">{% trans "nfs" %}</option>
<option value="glusterfs">{% trans "glusterfs" %}</option>
<option value="cifs">{% trans "cifs" %}</option>
</select>
</div>
</div>
<div class="row">
<label for="stg_netfs_target" class="col-sm-3 col-form-label">{% trans "Local Path" %}</label>
<div class="col-sm-6">
<input id="stg_netfs_target" type="text" class="form-control" name="target" placeholder="/srv/storage/" maxlength="100" required>
</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="create">
{% trans "Create" %}
</button>
</div>
</form>
</div>
<div class="tab-pane" id="isotab">
<form method="post" action="" role="form" aria-label="Create iso pool form">{% csrf_token %}
<div class="modal-body">
<div class="row">
<label for="stg_iso_name" class="col-sm-3 col-form-label">{% trans "Name" %}</label>
<div class="col-sm-6">
<input id="stg_iso_name" type="text" class="form-control" name="name" value="iso" disabled>
<input type="hidden" name="name" value="iso">
<input type="hidden" name="stg_type" value="dir">
</div>
</div>
<div class="row">
<label for="stg_iso_target" class="col-sm-3 col-form-label">{% trans "Path" %}</label>
<div class="col-sm-6">
<input id="stg_iso_target" type="text" class="form-control" name="target" value="/var/lib/libvirt/iso" required>
</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="create">
{% trans "Create" %}
</button>
</div>
</form>
{% else %}
{% trans "ISO images" as title %}{% trans "The directory pool named iso, for uploaded ISO images." as help %}
{% include "storages/pool_kind.html" with kind="iso" %}
{% endif %}
</div>
</fieldset>
<div class="mb-3">
<label for="pool_name" class="form-label">{% trans "Pool name" %}</label>
<input id="pool_name" type="text" class="form-control{{ form.name|invalid }}" name="name" value="{{ form.name.value|default:"" }}"
placeholder="vm-images" maxlength="20" required pattern="[a-zA-Z0-9._\-]+" {% error_attrs form.name "pool_name" "pool_name_help" %}>
<div class="form-text" id="pool_name_help">{% trans "Letters, digits, dots, dashes and underscores; up to 20." %}</div>
{% field_error form.name "pool_name" %}
</div>
</div> <!-- /.modal-body -->
</div> <!-- /.modal-content -->
</div> <!-- /.modal-dialog -->
</div> <!-- /.modal -->
<fieldset data-kind="dir">
<legend class="visually-hidden">{% trans "Directory" %}</legend>
<div class="mb-3">
<label for="pool_dir_target" class="form-label">{% trans "Directory" %}</label>
<input id="pool_dir_target" type="text" class="form-control font-monospace{% if pool_kind == "dir" %}{{ form.target|invalid }}{% endif %}" name="target" value="{% if pool_kind == "dir" %}{{ form.target.value|default:"" }}{% endif %}"
data-follow-name="/var/lib/libvirt/" placeholder="/var/lib/libvirt/vm-images" maxlength="100" required {% if pool_kind == "dir" %}{% error_attrs form.target "pool_dir_target" "pool_dir_target_help" %}{% else %}aria-describedby="pool_dir_target_help"{% endif %}>
<div class="form-text" id="pool_dir_target_help">{% trans "Made on the host if it does not exist." %}</div>
{% if pool_kind == "dir" %}{% field_error form.target "pool_dir_target" %}{% endif %}
</div>
</fieldset>
<fieldset data-kind="logical">
<legend class="visually-hidden">{% trans "LVM" %}</legend>
<div class="mb-3">
<label for="pool_lvm_source" class="form-label">{% trans "Device" %}</label>
<input id="pool_lvm_source" type="text" class="form-control font-monospace{% if pool_kind == "logical" %}{{ form.source|invalid }}{% endif %}" name="source" value="{% if pool_kind == "logical" %}{{ form.source.value|default:"" }}{% endif %}"
placeholder="/dev/sdb" maxlength="100" required {% if pool_kind == "logical" %}{% error_attrs form.source "pool_lvm_source" "pool_lvm_source_help" %}{% else %}aria-describedby="pool_lvm_source_help"{% endif %}>
<div class="form-text" id="pool_lvm_source_help">{% trans "The volume group takes the pool's name." %}</div>
{% if pool_kind == "logical" %}{% field_error form.source "pool_lvm_source" %}{% endif %}
</div>
<div class="alert alert-danger">
<div class="form-check mb-0">
<input class="form-check-input{{ form.erase_device|invalid }}" type="checkbox" name="erase_device" value="true" id="pool_lvm_erase" required {% error_attrs form.erase_device "pool_lvm_erase" %}>
<label class="form-check-label" for="pool_lvm_erase">{% trans "The device becomes a new volume group: what is on it now is erased." %}</label>
{% field_error form.erase_device "pool_lvm_erase" %}
</div>
</div>
</fieldset>
<fieldset data-kind="rbd">
<legend class="visually-hidden">{% trans "Ceph RBD" %}</legend>
{% if ceph_secrets %}
<div class="row g-3 mb-3">
<div class="col-sm-6">
<label for="pool_ceph_host" class="form-label">{% trans "Ceph monitor" %}</label>
<input id="pool_ceph_host" type="text" class="form-control{{ form.ceph_host|invalid }}" name="ceph_host" value="{{ form.ceph_host.value|default:"" }}"
placeholder="mon0.example.com" maxlength="253" required pattern="[a-zA-Z0-9._\-]+" {% error_attrs form.ceph_host "pool_ceph_host" %}>
{% field_error form.ceph_host "pool_ceph_host" %}
</div>
<div class="col-sm-6">
<label for="pool_ceph_pool" class="form-label">{% trans "Ceph pool" %}</label>
<input id="pool_ceph_pool" type="text" class="form-control{{ form.ceph_pool|invalid }}" name="ceph_pool" value="{{ form.ceph_pool.value|default:"" }}"
placeholder="libvirt-pool" required pattern="[a-zA-Z0-9._\-]+" {% error_attrs form.ceph_pool "pool_ceph_pool" %}>
{% field_error form.ceph_pool "pool_ceph_pool" %}
</div>
<div class="col-sm-6">
<label for="pool_ceph_user" class="form-label">{% trans "Ceph user" %}</label>
<input id="pool_ceph_user" type="text" class="form-control{{ form.ceph_user|invalid }}" name="ceph_user" value="{{ form.ceph_user.value|default:"" }}"
placeholder="libvirt" required pattern="[a-zA-Z0-9._\-]+" {% error_attrs form.ceph_user "pool_ceph_user" %}>
{% field_error form.ceph_user "pool_ceph_user" %}
</div>
<div class="col-sm-6">
<label for="pool_ceph_secret" class="form-label">{% trans "Ceph secret" %}</label>
<select id="pool_ceph_secret" name="secret" class="form-select{{ form.secret|invalid }}" required {% error_attrs form.secret "pool_ceph_secret" "pool_ceph_secret_help" %}>
<option value="">{% trans "Choose…" %}</option>
{% for secret in ceph_secrets %}
<option value="{{ secret.uuid }}"{% if secret.uuid == form.secret.value %} selected{% endif %}>{{ secret.usage }}</option>
{% endfor %}
</select>
<div class="form-text" id="pool_ceph_secret_help">{% trans "The libvirt secret holding that user's key." %}</div>
{% field_error form.secret "pool_ceph_secret" %}
</div>
</div>
{% else %}
<div class="alert alert-warning">
{% url 'virtsecrets' compute.id as secrets_url %}
{% blocktrans %}A Ceph pool needs a libvirt secret of the Ceph type holding the Ceph user's key, and this host has none. <a href="{{ secrets_url }}">Add one on the Secrets page</a>, then come back.{% endblocktrans %}
</div>
{% endif %}
</fieldset>
<fieldset data-kind="netfs">
<legend class="visually-hidden">{% trans "Network filesystem" %}</legend>
<div class="row g-3 mb-3">
<div class="col-sm-6">
<label for="pool_netfs_host" class="form-label">{% trans "Server" %}</label>
<input id="pool_netfs_host" type="text" class="form-control{{ form.netfs_host|invalid }}" name="netfs_host" value="{{ form.netfs_host.value|default:"" }}"
placeholder="nfs.example.com" maxlength="253" required pattern="[a-zA-Z0-9._\-]+" {% error_attrs form.netfs_host "pool_netfs_host" %}>
{% field_error form.netfs_host "pool_netfs_host" %}
</div>
<div class="col-sm-6">
<label for="pool_netfs_format" class="form-label">{% trans "Protocol" %}</label>
<select id="pool_netfs_format" name="source_format" class="form-select">
{% for value, label in form.fields.source_format.choices %}
<option value="{{ value }}"{% if value == form.source_format.value %} selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="col-sm-6">
<label for="pool_netfs_source" class="form-label">{% trans "Share on the server" %}</label>
<input id="pool_netfs_source" type="text" class="form-control font-monospace{% if pool_kind == "netfs" %}{{ form.source|invalid }}{% endif %}" name="source" value="{% if pool_kind == "netfs" %}{{ form.source.value|default:"" }}{% endif %}"
placeholder="/srv/storage" maxlength="100" required {% if pool_kind == "netfs" %}{% error_attrs form.source "pool_netfs_source" %}{% endif %}>
{% if pool_kind == "netfs" %}{% field_error form.source "pool_netfs_source" %}{% endif %}
</div>
<div class="col-sm-6">
<label for="pool_netfs_target" class="form-label">{% trans "Mounted on" %}</label>
<input id="pool_netfs_target" type="text" class="form-control font-monospace{% if pool_kind == "netfs" %}{{ form.target|invalid }}{% endif %}" name="target" value="{% if pool_kind == "netfs" %}{{ form.target.value|default:"" }}{% endif %}"
data-follow-name="/var/lib/libvirt/" placeholder="/var/lib/libvirt/nfs-share" maxlength="100" required {% if pool_kind == "netfs" %}{% error_attrs form.target "pool_netfs_target" "pool_netfs_target_help" %}{% else %}aria-describedby="pool_netfs_target_help"{% endif %}>
<div class="form-text" id="pool_netfs_target_help">{% trans "A directory of the host; made if it does not exist." %}</div>
{% if pool_kind == "netfs" %}{% field_error form.target "pool_netfs_target" %}{% endif %}
</div>
</div>
</fieldset>
{% if not iso_pool %}
<fieldset data-kind="iso">
<legend class="visually-hidden">{% trans "ISO images" %}</legend>
<div class="mb-3">
<label for="pool_iso_target" class="form-label">{% trans "Directory" %}</label>
<input id="pool_iso_target" type="text" class="form-control font-monospace{% if pool_kind == "iso" %}{{ form.target|invalid }}{% endif %}" name="target"
value="{% if pool_kind == "iso" and form.target.value %}{{ form.target.value }}{% else %}/var/lib/libvirt/iso{% endif %}" maxlength="100" required {% if pool_kind == "iso" %}{% error_attrs form.target "pool_iso_target" %}{% endif %}>
{% if pool_kind == "iso" %}{% field_error form.target "pool_iso_target" %}{% endif %}
</div>
</fieldset>
{% endif %}
<p class="small text-body-secondary mb-0">{% bs_icon 'info-circle' %} {% trans "The pool starts at once, and with the host from then on." %}</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% trans "Cancel" %}</button>
<button type="submit" class="btn btn-primary" name="create">{% trans "Create pool" %}</button>
</div>
</form>
</div>
</div>
<script>
// the chosen type's fields show; the ISO pool is a directory named iso;
// a path follows the pool's name until it is edited (after the page's
// scripts, Bootstrap's among them, have loaded)
document.addEventListener('DOMContentLoaded', function () {
var form = document.getElementById('pool-form');
var name = form.elements.namedItem('name');
var otherName = name.value;
var hasCephSecrets = {{ ceph_secrets|yesno:"true,false" }};
function choose(kind) {
var iso = kind === 'iso';
if (!name.readOnly) otherName = name.value;
name.value = iso ? 'iso' : otherName;
name.readOnly = iso;
form.elements.namedItem('stg_type').value = iso ? 'dir' : kind;
form.querySelectorAll('fieldset[data-kind]').forEach(function (fields) {
fields.hidden = fields.disabled = fields.dataset.kind !== kind;
});
form.querySelector('button[name=create]').disabled = kind === 'rbd' && !hasCephSecrets;
}
function follow() {
form.querySelectorAll('[data-follow-name]').forEach(function (path) {
var derived = name.value ? path.dataset.followName + name.value : '';
if (!path.value || path.value === path.dataset.derived) path.value = derived;
path.dataset.derived = derived;
});
}
form.querySelectorAll('input[name=pool_kind]').forEach(function (radio) {
radio.addEventListener('change', function () { choose(radio.value); });
});
name.addEventListener('input', follow);
// a new device or name: the erasing is agreed to again
form.querySelectorAll('#pool_lvm_source, #pool_name').forEach(function (field) {
field.addEventListener('input', function () { document.getElementById('pool_lvm_erase').checked = false; });
});
form.querySelectorAll('[data-follow-name]').forEach(function (path) {
path.dataset.derived = name.value ? path.dataset.followName + name.value : '';
});
// the type sent back, or the first when it is offered no more (an iso pool made meanwhile)
var chosen = form.querySelector('input[name=pool_kind]:checked') || form.querySelector('input[name=pool_kind]');
chosen.checked = true;
choose(chosen.value);
// a refused pool: the dialog opens again with what was entered and why,
// the first refused field focused, or the reasons when none is shown
var dialog = document.getElementById('AddStgPool');
if (dialog.hasAttribute('data-open')) {
dialog.addEventListener('shown.bs.modal', function () {
var refused = form.querySelector('fieldset:not([hidden]) [aria-invalid=true], #pool_name[aria-invalid=true]');
var reasons = form.querySelector('.form-errors');
if (refused) {
refused.focus();
} else if (reasons) {
reasons.tabIndex = -1;
reasons.focus();
}
}, {once: true});
bootstrap.Modal.getOrCreateInstance(dialog).show();
}
});
</script>
{% endif %}

View file

@ -4,16 +4,16 @@
{% if request.user.is_superuser %}
{% if state != 0 %}
{% if pool == "iso" %}
<a href="#IsoUpload" class="btn btn-success float-end" data-bs-toggle="modal">
<span aria-hidden="true">{% bs_icon 'plus-circle-fill' %} </span>
</a>
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#IsoUpload">
<span aria-hidden="true">{% bs_icon 'upload' %}</span> {% trans "Upload ISO" %}
</button>
<!-- Modal Upload -->
<div class="modal fade" id="IsoUpload" tabindex="-1" role="dialog" aria-labelledby="IsoUploadLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{% trans "Upload ISO Image" %}</h5>
<h5 class="modal-title" id="IsoUploadLabel">{% trans "Upload ISO Image" %}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form id="isoUploadForm" enctype="multipart/form-data" method="post" role="form" aria-label="Upload iso form">{% csrf_token %}
@ -41,16 +41,16 @@
</div> <!-- /.modal-dialog -->
</div> <!-- /.modal -->
{% else %}
<a href="#AddImage" class="btn btn-success float-end" title="{% trans "Add New Volume" %}" data-bs-toggle="modal">
<span aria-hidden="true">{% bs_icon 'plus-circle-fill' %} </span>
</a>
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#AddImage">
<span aria-hidden="true">{% bs_icon 'plus-lg' %}</span> {% trans "New volume" %}
</button>
<!-- Modal Image -->
<div class="modal fade" id="AddImage" tabindex="-1" role="dialog" aria-labelledby="AddImageLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{% trans "Add New Volume" %}</h5>
<h5 class="modal-title" id="AddImageLabel">{% trans "Add New Volume" %}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">

View file

@ -7,7 +7,47 @@
{% block page_heading %}{{ compute }} - {% trans "Storage" %}: {{ pool }}{% endblock page_heading %}
{% block page_heading_extra %}{% include 'create_stg_vol_block.html' %}{% endblock page_heading_extra %}
{% block page_heading_extra %}
<div class="d-flex flex-wrap gap-2">
{% include 'create_stg_vol_block.html' %}
{# the pool's own actions: starting or stopping it, the others in a menu #}
<div class="btn-group" role="group" aria-label="{% blocktrans %}Actions for pool {{ pool }}{% endblocktrans %}">
{% if state %}
<form method="post" class="btn-group">{% csrf_token %}
<button type="submit" class="btn btn-outline-secondary" name="stop"
data-confirm="{% blocktrans with name=pool %}Stop pool {{ name }}? Its files stay, but until it is started again its volumes cannot be managed and VMs with disks in it may not start.{% endblocktrans %}"
onclick="return confirm(this.dataset.confirm)">{% bs_icon 'stop-fill' %} {% trans "Stop" %}</button>
</form>
{% else %}
<form method="post" class="btn-group">{% csrf_token %}
<button type="submit" class="btn btn-outline-secondary" name="start">{% bs_icon 'play-fill' %} {% trans "Start" %}</button>
</form>
{% endif %}
<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 pool {{ name }}? After the host restarts it stays stopped.{% 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 pool {{ name }}? Only its definition goes; its volumes and their files stay.{% endblocktrans %}"
onclick="return confirm(this.dataset.confirm)">{% bs_icon 'trash' %} {% trans "Delete pool" %}</button>
</form></li>
{% endif %}
</ul>
</div>
</div>
</div>
{% endblock page_heading_extra %}
{% block content %}
<div id="job-panel" aria-live="polite"></div>
@ -17,103 +57,107 @@
{% include "computes/host_nav.html" with section="storages" detail=True %}
</div>
</div>
<div class="row">
<div class="col-lg-12">
<dl class="row">
<dt class="col-6">{% trans "Pool name" %}</dt>
<dd class="col-6">{{ pool }}</dd>
<dt class="col-6">{% trans "Pool type" %}</dt>
<dd class="col-6">{% if not type %}{% trans "None" %}{% else %}{{ type }}{% endif %}</dd>
<dt class="col-6">{% trans "Pool path" %}</dt>
<dd class="col-6">{% if not path %}{% trans "None" %}{% else %}{{ path }}{% endif %}</dd>
<dt class="col-6">{% trans "Pool status" %}</dt>
<dd class="col-6">{% if not status %}{% trans "None" %}{% else %}{{ status }}{% endif %}</dd>
{% if not state %}
<dt class="col-6">{% trans "Size" %}</dt>
<dd class="col-6">{% trans "Unavailable while the pool is stopped." %}</dd>
{% elif size is None %}
<dt class="col-6">{% trans "Size" %}</dt>
<dd class="col-6">{% trans "The pool's space could not be read." %}</dd>
{% else %}
<dt class="col-6">{% trans "Size" %} </dt>
<dd class="col-6">{{size|filesizeformat }}</dd>
<dt class="col-6">{% trans "Usage" %} ({{ percent }}%)</dt>
<dd class="col-6">{{ used|filesizeformat }}</dd>
{% endif %}
<dt class="col-6">{% trans "State" %}</dt>
<dd class="col-6">
<form action="" method="post" role="form" aria-label="Storage start/stop form">{% csrf_token %}
{% if state == 0 %}
<input type="submit" class="btn btn-sm btn-secondary" name="start" value="{% trans "Start" %}">
<input type="submit" class="btn btn-sm btn-danger" name="delete" value="{% trans "Delete" %}"
data-confirm="{% blocktrans with name=pool %}Delete pool {{ name }}? Only its definition goes; its volumes and their files stay.{% endblocktrans %}" onclick="return confirm(this.dataset.confirm)">
{% else %}
<input type="submit" class="btn btn-sm btn-secondary" name="stop" value="{% trans "Stop" %}"
data-confirm="{% blocktrans with name=pool %}Stop pool {{ name }}? Its files stay, but until it is started again its volumes cannot be managed and VMs with disks in it may not start.{% endblocktrans %}" onclick="return confirm(this.dataset.confirm)">
{% endif %}
</form>
</dd>
<dt class="col-6">{% trans "Autostart" %}</dt>
<dd class="col-6">
<form action="" method="post" role="form" aria-label="Storage disable/enable autostart form">{% csrf_token %}
{% if autostart == 0 %}
<input type="submit" class="btn btn-sm btn-secondary" name="set_autostart"
value="{% trans "Enable" %}">
{% else %}
<input type="submit" class="btn btn-sm btn-secondary" name="unset_autostart"
value="{% trans "Disable" %}"
data-confirm="{% blocktrans with name=pool %}Disable autostart of pool {{ name }}? After the host restarts it stays stopped.{% endblocktrans %}" onclick="return confirm(this.dataset.confirm)">
<div class="row g-3 mb-4">
<div class="col-12 col-md-6">
<div class="card h-100">
<div class="card-header">{% trans "Space" %}</div>
<div class="card-body">
{% if not state %}
<div class="text-body-secondary">{% trans "Unavailable while the pool is stopped." %}</div>
{% elif size is None %}
<div class="text-body-secondary">{% trans "The pool's space could not be read." %}</div>
{% elif not size %}
<div class="text-body-secondary">{% trans "The pool has no size." %}</div>
{% else %}
<div class="fs-4 lh-sm">{{ percent }} %</div>
<div class="small text-body-secondary">
{% blocktrans with used=used|filesizeformat size=size|filesizeformat %}{{ used }} used of {{ size }}{% endblocktrans %}
{# what the space is the space of: other pools may share it #}
{% if type == "dir" or type == "fs" or type == "netfs" %}· {% trans "the filesystem of its path" %}
{% elif type == "logical" %}· {% trans "its volume group" %}
{% elif type == "rbd" %}· {% trans "the Ceph cluster" %}{% endif %}
</div>
<div class="progress my-2" role="progressbar" style="--bs-progress-height: 0.3rem;" aria-label="{% trans "Space used" %}"
aria-valuenow="{{ percent }}" aria-valuemin="0" aria-valuemax="100">
<div class="progress-bar {% if percent > 90 %}bg-danger{% elif percent > 75 %}bg-warning{% endif %}" style="width: {{ percent }}%"></div>
</div>
<div class="small text-body-secondary">{% blocktrans with free=free|filesizeformat %}{{ free }} free{% endblocktrans %}</div>
{% endif %}
</form>
</dd>
</dl>
</div>
</div>
</div>
<div class="col-12 col-md-6">
<div class="card h-100">
<div class="card-header">{% trans "Pool" %}</div>
<ul class="list-group list-group-flush">
<li class="list-group-item d-flex justify-content-between 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 status == "Running degraded" %}<span class="badge text-bg-warning">{% trans "Degraded" %}</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 gap-3">
<span class="text-body-secondary">{% trans "Type" %}</span><span>{{ type|default:"—" }}</span>
</li>
<li class="list-group-item d-flex justify-content-between gap-3">
<span class="text-body-secondary">{% trans "Path" %}</span>
<span class="font-monospace text-break text-end">{{ path|default:"—" }}</span>
</li>
</ul>
</div>
</div>
</div>
{% if not state %}
<div class="alert alert-secondary">
{% bs_icon 'info-circle' %} {% trans "This pool is stopped: its volumes cannot be listed or managed until it is started. Their files stay." %}
</div>
{% elif volumes %}
<div class="clearfix">
{% include 'search_block.html' %}
<h5 class="mb-3">{% trans "Volumes" %}</h5>
<div class="d-flex flex-wrap align-items-center gap-2 mb-2">
<h5 class="me-auto mb-0">{% trans "Volumes" %}</h5>
<input id="filter" class="form-control w-auto" type="search" placeholder="{% trans "Search volumes" %}" aria-label="{% trans "Search volumes" %}">
</div>
<div class="table-responsive">
<table class="table table-hover align-middle" data-sortable>
<thead>
<tr>
<th scope="col">{% trans "Name" %}</th>
<th scope="col" data-sort-method="number">{% trans "Allocated" %}</th>
<th scope="col" data-sort-method="number">{% trans "Size" %}</th>
<th scope="col">{% trans "Format" %}</th>
<th scope="colgroup" data-sort-method="none" colspan="2">{% trans "Action" %}</th>
<th scope="col" class="d-none d-sm-table-cell" data-sort-method="number">{% trans "Capacity" %}</th>
<th scope="col" data-sort-method="none"><span class="visually-hidden">{% trans "Actions" %}</span></th>
</tr>
</thead>
<tbody class="searchable">
{% for volume in volumes %}
<tr data-search="{{ volume.name }}">
<td class="text-break">{{ volume.name }}</td>
<td data-sort="{{ volume.allocation }}">{{ volume.allocation|filesizeformat }}</td>
<td data-sort="{{ volume.size }}">{{ volume.size|filesizeformat }}</td>
<td>{{ volume.type }}</td>
<td>
{# an ISO image is uploaded, not cloned (storages.jobs.is_iso) #}
<button type="button" class="btn btn-sm btn-secondary" data-bs-toggle="modal" data-bs-target="#CloneVolume" data-volume="{{ volume.name }}"
title="{% trans "Clone" %}" aria-label="{% blocktrans with name=volume.name %}Clone {{ name }}{% endblocktrans %}"{% if volume.iso %} disabled{% endif %}>
{% bs_icon 'files' %}
</button>
<td><span class="badge text-bg-light border">{{ volume.type }}</span></td>
{# capacity: what the guest sees; allocated: what it takes on the storage now #}
<td class="d-none d-sm-table-cell" data-sort="{{ volume.size }}">
<div>{{ volume.size|filesizeformat }}</div>
<div class="small text-body-secondary">{% blocktrans with allocated=volume.allocation|filesizeformat %}{{ allocated }} allocated{% endblocktrans %}</div>
</td>
<td>
<form action="" method="post" aria-label="{% trans "Delete volume" %}">{% csrf_token %}
<input type="hidden" name="volname" value="{{ volume.name }}">
<button type="submit" class="btn btn-sm btn-secondary" name="del_volume" title="{% trans "Delete" %}"
aria-label="{% blocktrans with name=volume.name %}Delete {{ name }}{% endblocktrans %}"
data-confirm="{% blocktrans with name=volume.name %}Delete volume {{ name }}? Its file and data are removed; a volume a VM uses is not deleted.{% endblocktrans %}" onclick="return confirm(this.dataset.confirm)">
{% bs_icon 'trash' %}
</button>
</form>
<td class="text-end text-nowrap">
<div class="btn-group" role="group" aria-label="{% blocktrans with name=volume.name %}Actions for {{ name }}{% endblocktrans %}">
{# an ISO image is uploaded, not cloned (storages.jobs.is_iso) #}
<button type="button" class="btn btn-sm btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#CloneVolume" data-volume="{{ volume.name }}"
aria-label="{% trans "Clone" %}"{% if volume.iso %} disabled{% endif %}>{% bs_icon 'files' %} <span class="d-none d-sm-inline">{% trans "Clone" %}</span></button>
<div class="btn-group">
{# fixed: the menu would be cut off by the table's scrolling box #}
<button type="button" class="btn btn-sm btn-outline-secondary" data-bs-toggle="dropdown" aria-expanded="false"
data-bs-popper-config='{"strategy": "fixed"}' aria-label="{% trans "More actions" %}">{% bs_icon 'three-dots' %}</button>
<ul class="dropdown-menu dropdown-menu-end shadow">
<li><form method="post">{% csrf_token %}
<input type="hidden" name="volname" value="{{ volume.name }}">
<button type="submit" class="dropdown-item text-danger" name="del_volume"
data-confirm="{% blocktrans with name=volume.name %}Delete volume {{ name }}? Its file and data are removed; a volume a VM uses is not deleted.{% endblocktrans %}"
onclick="return confirm(this.dataset.confirm)">{% bs_icon 'trash' %} {% trans "Delete" %}</button>
</form></li>
</ul>
</div>
</div>
</td>
</tr>
{% endfor %}
@ -174,9 +218,7 @@
</div>
</div>
{% else %}
<div class="alert alert-warning shadow-sm">
{% bs_icon 'exclamation-triangle' %} {% trans "This pool has no volumes." %}
</div>
<div class="text-center text-body-secondary py-4">{% trans "This pool has no volumes." %}</div>
{% endif %}
{% endblock %}

View file

@ -15,35 +15,50 @@
</div>
</div>
<div class="row">
{% if not storages %}
<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 Storages" %}
{% if not storages %}
<div class="alert alert-warning shadow-sm">
{% bs_icon 'exclamation-triangle' %} {% trans "This host has no storage pools." %}
</div>
{% else %}
{# a card per pool, as the host Overview's Hardware and Software cards #}
<div class="row row-cols-1 row-cols-md-2 g-3 mb-4">
{% for pool in storages %}
<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 'storage' compute.id pool.name %}">{{ pool.name }}</a>
{% if pool.status %}
<span class="badge text-bg-success">{% trans "Active" %}</span>
{% else %}
<span class="badge text-bg-secondary">{% trans "Stopped" %}</span>
{% endif %}
{% if pool.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">{{ pool.type|upper }}</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-baseline gap-3">
<span class="text-body-secondary">{% trans "Path" %}</span>
<span class="small font-monospace text-end text-break">{{ pool.path|default:"–" }}</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-baseline gap-3">
<span class="text-body-secondary">{% trans "Volumes" %}</span>
<span class="fw-semibold">{% if pool.status %}{{ pool.volumes }}{% else %}–{% endif %}</span>
</li>
{# the space of what the pool stores in: its filesystem, volume group or cluster, which other pools may share #}
<li class="list-group-item">
{% if pool.status and pool.size %}
{% include "computes/pool_bar.html" with hide_name=True %}
{% else %}
<span class="small text-body-secondary">{% if pool.status %}{% trans "No size" %}{% else %}{% trans "Space unavailable while stopped" %}{% endif %}</span>
{% endif %}
</li>
</ul>
</div>
</div>
{% else %}
{% for pool in storages %}
<div class="col-12 col-md-4">
<div class="mb-3 card {% if pool.status %}border-success{% else %}border-danger{% endif %} shadow-sm">
<div class="card-header {% if pool.status %}bg-success-subtle{% else %}bg-danger-subtle{% endif %}">
<h6 class="my-0 card-title">
<a class="card-link {% if pool.status %}text-success-emphasis{% else %}text-danger-emphasis{% endif %}" href="{% url 'storage' compute.id pool.name %}">{{ pool.name }}</a>
</h6>
</div>
<div class="card-body">
<dl class="row">
<dt class="col-6">{% trans "Type" %}</dt>
<dd class="col-6">{{ pool.type|upper }}</dd>
<dt class="col-6">{% trans "Size" %}</dt>
<dd class="col-6">{{ pool.size|filesizeformat }}</dd>
<dt class="col-6">{% trans "Volumes" %}</dt>
<dd class="col-6">{{ pool.volumes|default_if_none:"" }}</dd>
</dl>
</div>
</div>
</div>
{% endfor %}
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
{% endblock %}

View file

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

View file

@ -167,13 +167,15 @@ class PoolAndVolumeRowTests(TransactionTestCase):
url = reverse("storages", args=[self.compute.pk])
self.client.post(url, {"create": "1", "name": "audit-pool", "stg_type": "dir", "target": "/srv/audit-pool"})
self.client.post(url, {"create": "1", "name": "bad name!", "stg_type": "dir", "target": "/srv/x"}) # invalid
self.pools.get_storages_info.return_value = [{"name": "default"}]
self.pools.get_storages_info.return_value = [{"name": "default", "type": "dir", "path": "/var/lib/libvirt/images"}]
self.pools.create_storage.reset_mock()
self.client.post(url, {"create": "1", "name": "default", "stg_type": "dir", "target": "/srv/default"})
self.client.post(url, {"create": "1", "name": " default ", "stg_type": "dir", "target": "/srv/default"}) # trimmed
self.pools.create_storage.assert_not_called()
self.assertEqual(rows(), [
("Storage pool audit-pool: add", True),
("Storage pool default: add: name already in use", False),
("Storage pool default: add: name already in use", False),
])
def test_volume_add_and_delete(self):

View file

@ -0,0 +1,129 @@
"""The new pool dialog: each type needs its own fields, a pool takes no name or
path another one has, an RBD pool only a Ceph secret of the host; a refused
pool opens the dialog again with what was entered and why."""
from unittest.mock import MagicMock, patch
from computes.models import Compute
from django.contrib.auth import get_user_model
from django.test import SimpleTestCase, TestCase
from django.urls import reverse
from libvirt import VIR_SECRET_USAGE_TYPE_CEPH, VIR_SECRET_USAGE_TYPE_VOLUME, libvirtError
from lxml import html
from storages.forms import AddStgPool
POOLS = [
{"name": "default", "type": "dir", "path": "/var/lib/libvirt/images"},
{"name": "ceph-vms", "type": "rbd", "path": "vms"}, # an RBD pool's path is its Ceph pool
]
SECRET = "00000000-0000-0000-0000-000000000001"
def errors(data):
form = AddStgPool(data, pools=POOLS, ceph_secrets=[SECRET])
form.is_valid()
return sorted(form.errors)
class FormTests(SimpleTestCase):
def test_each_type_needs_its_fields(self):
for stg_type, needed in (
("dir", ["target"]),
("logical", ["erase_device", "source"]),
("rbd", ["ceph_host", "ceph_pool", "ceph_user", "secret"]),
("netfs", ["netfs_host", "source", "target"]),
):
with self.subTest(stg_type=stg_type):
self.assertEqual(errors({"name": "new", "stg_type": stg_type}), needed)
def test_a_pool_takes_no_name_or_path_of_another(self):
self.assertEqual(errors({"name": "default", "stg_type": "dir", "target": "/srv/new"}), ["name"])
self.assertEqual(errors({"name": "new", "stg_type": "dir", "target": "/var/lib/libvirt/images/"}), ["target"])
self.assertEqual(errors({"name": "new", "stg_type": "dir", "target": "vms"}), []) # not a directory
self.assertEqual(errors({"name": "..", "stg_type": "dir", "target": "/srv/new"}), ["name"])
def test_an_rbd_pool_takes_a_ceph_secret_of_the_host(self):
data = {"name": "new", "stg_type": "rbd", "ceph_host": "mon0.example.com", "ceph_pool": "vms", "ceph_user": "libvirt"}
self.assertEqual(errors({**data, "secret": SECRET}), [])
self.assertEqual(errors({**data, "secret": "00000000-0000-0000-0000-000000000002"}), ["secret"])
def test_an_lvm_pool_is_agreed_to_erase_its_device(self):
data = {"name": "vg-new", "stg_type": "logical", "source": "/dev/vdb"}
self.assertEqual(errors(data), ["erase_device"])
self.assertEqual(errors({**data, "erase_device": "true"}), [])
class DialogTests(TestCase):
def setUp(self):
self.compute = Compute.objects.create(name="np", hostname="192.0.2.25", login="root", password="", type=1)
self.client.force_login(get_user_model().objects.create_superuser("np-admin", "np@example.com", "pw"))
patcher = patch("storages.views.wvmStorages")
self.conn = patcher.start().return_value
self.addCleanup(patcher.stop)
pools = [{**pool, "status": 1, "autostart": 1, "volumes": 0, "size": 1, "free": 1, "used_percent": 0} for pool in POOLS]
self.conn.get_storages_info.return_value = pools
secrets = {SECRET: (VIR_SECRET_USAGE_TYPE_CEPH, "client.libvirt"),
"00000000-0000-0000-0000-000000000003": (VIR_SECRET_USAGE_TYPE_VOLUME, "/var/lib/libvirt/images/a.img")}
self.conn.get_secrets.return_value = list(secrets)
self.conn.get_secret.side_effect = lambda uuid: MagicMock(**{"usageType.return_value": secrets[uuid][0],
"usageID.return_value": secrets[uuid][1]})
self.url = reverse("storages", args=[self.compute.pk])
def page(self, response):
self.assertEqual(response.status_code, 200)
return html.fromstring(response.content)
def test_only_ceph_secrets_are_offered_by_their_names(self):
page = self.page(self.client.get(self.url))
self.assertEqual(page.xpath("//select[@name='secret']/option/text()"), ["Choose…", "client.libvirt"])
self.assertIsNone(page.get_element_by_id("AddStgPool").get("data-open"))
def test_an_iso_pool_on_the_host_is_linked_not_offered(self):
self.assertTrue(self.page(self.client.get(self.url)).xpath("//input[@name='pool_kind'][@value='iso']"))
self.conn.get_storages_info.return_value += [{"name": "iso", "type": "dir", "path": "/srv/iso", "status": 1,
"autostart": 1, "volumes": 0, "size": 1, "free": 1, "used_percent": 0}]
page = self.page(self.client.get(self.url))
self.assertFalse(page.xpath("//input[@name='pool_kind'][@value='iso']"))
self.assertTrue(page.xpath(f"//div[@id='AddStgPool']//a[@href='{reverse('storage', args=[self.compute.pk, 'iso'])}']"))
def test_a_refused_pool_opens_the_dialog_again_with_what_was_entered(self):
page = self.page(self.client.post(self.url, {"create": "1", "pool_kind": "netfs", "stg_type": "netfs",
"name": "share", "netfs_host": "nfs.example.com",
"source": "/export", "target": "/var/lib/libvirt/images"}))
self.conn.create_storage_netfs.assert_not_called()
self.assertIsNotNone(page.get_element_by_id("AddStgPool").get("data-open"))
self.assertEqual(page.xpath("//input[@name='pool_kind'][@checked]/@value"), ["netfs"])
self.assertEqual(page.get_element_by_id("pool_netfs_host").get("value"), "nfs.example.com")
target = page.get_element_by_id("pool_netfs_target")
self.assertEqual(target.get("aria-invalid"), "true")
self.assertIn("Pool default uses this path", page.get_element_by_id("pool_netfs_target_error").text_content())
def test_a_refused_type_keeps_its_values_to_itself(self):
# target and source are fields of several types: the others keep their own defaults
page = self.page(self.client.post(self.url, {"create": "1", "pool_kind": "netfs", "stg_type": "netfs",
"name": "share", "netfs_host": "nfs.example.com",
"source": "/export", "target": "/var/lib/libvirt/images"}))
self.assertEqual(page.get_element_by_id("pool_netfs_source").get("value"), "/export")
self.assertIsNone(page.get_element_by_id("pool_lvm_source").get("value") or None)
self.assertIsNone(page.get_element_by_id("pool_dir_target").get("aria-invalid"))
self.assertEqual(page.get_element_by_id("pool_iso_target").get("value"), "/var/lib/libvirt/iso")
def test_an_lvm_pool_refused_for_its_acknowledgement_says_so_beside_it(self):
page = self.page(self.client.post(self.url, {"create": "1", "pool_kind": "logical", "stg_type": "logical",
"name": "vg-new", "source": "/dev/vdb"}))
self.conn.create_storage.assert_not_called()
self.assertEqual(page.get_element_by_id("pool_lvm_erase").get("aria-invalid"), "true")
self.assertTrue(page.get_element_by_id("pool_lvm_erase_error").text_content())
def test_a_pool_libvirt_refuses_says_why_in_the_dialog(self):
self.conn.create_storage.side_effect = libvirtError("cannot create directory '/srv/new': Permission denied")
page = self.page(self.client.post(self.url, {"create": "1", "pool_kind": "dir", "stg_type": "dir",
"name": "new", "target": "/srv/new"}))
self.assertIn("Permission denied", page.xpath("string(//div[@id='AddStgPool']//div[contains(@class,'form-errors')])"))
def test_a_pool_made(self):
response = self.client.post(self.url, {"create": "1", "pool_kind": "iso", "stg_type": "dir",
"name": "iso", "target": "/var/lib/libvirt/iso"})
self.assertRedirects(response, reverse("storage", args=[self.compute.pk, "iso"]), fetch_redirect_response=False)
self.conn.create_storage.assert_called_once_with("dir", "iso", "", "/var/lib/libvirt/iso")

View file

@ -79,6 +79,18 @@ class PoolPageTests(TestCase):
self.assertEqual(clone, {"data.img": True, "install.iso": False, "Boot.ISO": False})
self.assertTrue(page.xpath("//div[@id='no-match'][@hidden]"))
def test_the_heading_offers_what_the_pool_state_allows(self):
# an active pool is stopped, a stopped one started or deleted; autostart either way
self.conn.get_size.return_value = (10, 4)
url = reverse("storage", args=[self.compute.pk, "default"])
for active, names in ((True, ["stop", "set_autostart"]), (False, ["start", "set_autostart", "delete"])):
with self.subTest(active=active):
self.conn.is_active.return_value = active
self.conn.get_autostart.return_value = 0
page = html.fromstring(self.client.get(url).content)
group = page.xpath("//div[@aria-label='Actions for pool default']")[0]
self.assertEqual(group.xpath(".//button[@type='submit']/@name"), names)
class PoolApiTests(TestCase):
"""The API's pool, like the page, is measured after its refresh; a stopped pool lists no volumes."""
@ -110,3 +122,30 @@ class PoolApiTests(TestCase):
self.assertEqual(response.status_code, 200, response.content)
self.assertIsNone(response.json()["volumes"])
self.conn.update_volumes.assert_not_called()
class PoolListTests(TestCase):
"""The pool list: a card per pool with its state, autostart, path and space; a stopped pool's space is not zero."""
def test_cards(self):
compute = Compute.objects.create(name="pl", hostname="192.0.2.23", login="root", password="", type=1)
self.client.force_login(get_user_model().objects.create_superuser("pl-admin", "pl@example.com", "pw"))
pools = [
{"name": "default", "status": 1, "autostart": 1, "type": "dir", "path": "/var/lib/libvirt/images",
"volumes": 3, "size": 100, "free": 60, "used_percent": 40.0},
{"name": "ceph-vms", "status": 1, "autostart": 0, "type": "rbd", "path": "vms",
"volumes": 9, "size": 100, "free": 5, "used_percent": 95.0},
{"name": "backup", "status": 0, "autostart": 0, "type": "dir", "path": "/srv/backup",
"volumes": None, "size": 0, "free": 0, "used_percent": 0},
]
with patch("storages.views.wvmStorages") as storages:
storages.return_value.get_storages_info.return_value = pools
storages.return_value.get_secrets.return_value = []
page = html.fromstring(self.client.get(reverse("storages", args=[compute.pk])).content)
cards = [" ".join(" ".join(card.itertext()).split()) for card in page.xpath("//div[@class='card h-100']")]
self.assertEqual(cards, [
"default Active Autostart Type DIR Path /var/lib/libvirt/images Volumes 3 60 bytes free of 100 bytes",
"ceph-vms Active Type RBD Path vms Volumes 9 5 bytes free of 100 bytes",
"backup Stopped Type DIR Path /srv/backup Volumes – Space unavailable while stopped",
])
self.assertEqual(page.xpath("//div[contains(@class,'progress-bar')]/@class"),
["progress-bar ", "progress-bar bg-danger"])

View file

@ -67,11 +67,12 @@ class PoolTargetTests(SimpleTestCase):
def test_the_create_form_leaves_the_path_check_to_the_server(self):
# a stricter pattern or a shorter maxlength in the page refuses paths the form accepts
superuser = SimpleNamespace(user=SimpleNamespace(is_superuser=True))
page = html.fromstring(render_to_string("create_stg_block.html", {"request": superuser}))
ceph = [{"uuid": "00000000-0000-0000-0000-000000000001", "usage": "client.libvirt"}]
page = html.fromstring(render_to_string("create_stg_block.html", {"request": superuser, "ceph_secrets": ceph}))
fields = page.xpath("//input[@name='target' or @name='source']")
self.assertEqual(len(fields), 5)
self.assertEqual(len(fields), 5) # directory, LVM device, share, its mount point, ISO directory
for field in fields:
with self.subTest(form=field.xpath("string(ancestor::form//input[@name='stg_type']/@value)"), name=field.name):
with self.subTest(id=field.get("id")):
self.assertIsNone(field.get("pattern"))
self.assertGreaterEqual(int(field.get("maxlength", 100)), 100)
for name in ("netfs_host", "ceph_host"): # a host name is up to 253 characters

View file

@ -10,7 +10,7 @@ from django.http import HttpResponseRedirect, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from libvirt import libvirtError
from libvirt import VIR_SECRET_USAGE_TYPE_CEPH, libvirtError
from operations import claims, jobs
from operations.utils import busy_message
from logs.audit import audit, failed
@ -68,71 +68,43 @@ def storages(request, compute_id):
"""
compute = get_object_or_404(Compute, pk=compute_id)
errors = False
form = AddStgPool()
# the new pool dialog's type: its radio cards tell the ISO pool from another directory
pool_kind = request.POST.get("pool_kind", "dir")
try:
conn = wvmStorages(
compute.hostname, compute.login, compute.password, compute.type
)
storages = conn.get_storages_info()
secrets = conn.get_secrets()
iso_pool = next((pool for pool in storages if pool["name"] == "iso"), None)
# the secrets an RBD pool can use, by the name they were given
ceph_secrets = []
for uuid in conn.get_secrets():
secret = conn.get_secret(uuid)
if secret.usageType() == VIR_SECRET_USAGE_TYPE_CEPH:
ceph_secrets.append({"uuid": uuid, "usage": secret.usageID()})
if request.method == "POST":
if "create" in request.POST:
form = AddStgPool(request.POST)
if form.is_valid():
data = form.cleaned_data
if data["name"] in [stg["name"] for stg in storages]:
msg = _("Pool name already use")
messages.error(request, msg)
failed(request, compute, f"Storage pool {data['name']}", "add", "name already in use")
errors = True
if data["stg_type"] == "rbd":
if not data["secret"]:
msg = _("You need create secret for pool")
messages.error(request, msg)
errors = True
if (
not data["ceph_pool"]
and not data["ceph_host"]
and not data["ceph_user"]
):
msg = _("You need input all fields for creating ceph pool")
messages.error(request, msg)
errors = True
if not errors:
with audit(request, compute, f"Storage pool {data['name']}", "add"):
if data["stg_type"] == "rbd":
conn.create_storage_ceph(
data["stg_type"],
data["name"],
data["ceph_pool"],
data["ceph_host"],
data["ceph_user"],
data["secret"],
)
elif data["stg_type"] == "netfs":
conn.create_storage_netfs(
data["stg_type"],
data["name"],
data["netfs_host"],
data["source"],
data["source_format"],
data["target"],
)
else:
conn.create_storage(
data["stg_type"],
data["name"],
data["source"],
data["target"],
)
return HttpResponseRedirect(
reverse("storage", args=[compute_id, data["name"]])
)
if request.method == "POST" and "create" in request.POST:
form = AddStgPool(request.POST, pools=storages, ceph_secrets=[s["uuid"] for s in ceph_secrets])
if form.is_valid():
data = form.cleaned_data
try:
with audit(request, compute, f"Storage pool {data['name']}", "add"):
if data["stg_type"] == "rbd":
conn.create_storage_ceph(data["stg_type"], data["name"], data["ceph_pool"],
data["ceph_host"], data["ceph_user"], data["secret"])
elif data["stg_type"] == "netfs":
conn.create_storage_netfs(data["stg_type"], data["name"], data["netfs_host"],
data["source"], data["source_format"], data["target"])
else:
conn.create_storage(data["stg_type"], data["name"], data["source"], data["target"])
except libvirtError as err: # shown in the dialog, which opens again
form.add_error(None, str(err))
else:
for msg_err in form.errors.values():
messages.error(request, msg_err.as_text())
return HttpResponseRedirect(reverse("storage", args=[compute_id, data["name"]]))
elif form.has_error("name", "taken"):
failed(request, compute, f"Storage pool {form.data['name'].strip()}", "add", "name already in use")
conn.close()
except libvirtError as lib_err:
messages.error(request, lib_err)
@ -179,7 +151,7 @@ def storage(request, compute_id, pool):
except libvirtError: # both read the pool's info: the page says it could not be read
size = free = used = percent = status = None
path = conn.get_target_path()
path = conn.get_target_path() or conn.get_source_name() # an RBD pool: its Ceph pool
type = conn.get_type()
autostart = conn.get_autostart()

View file

@ -7,8 +7,9 @@ from vrtManager.connection import wvmConnect
class wvmStorages(wvmConnect):
def get_storages_info(self):
"""Each pool's state, type, volume count, size and free space. An active
pool is refreshed first: libvirt measures its space only when it is
"""Each pool's state, autostart, type, path (an RBD pool's Ceph pool),
volume count, size, free space and the percent used. An active pool
is refreshed first: libvirt measures its space only when it is
refreshed, so two hosts sharing a pool's storage told different free
space, each as of its own last refresh."""
get_storages = self.get_storages()
@ -19,17 +20,20 @@ class wvmStorages(wvmConnect):
if stg_status:
with contextlib.suppress(libvirt.libvirtError): # refused while a volume is cloned in it
stg.refresh(0)
stg_type = util.get_xml_path(stg.XMLDesc(0), "/pool/@type")
xml = stg.XMLDesc(0)
stg_vol = len(stg.listVolumes()) if stg_status else None
_state, stg_size, _allocation, stg_free = stg.info()
storages.append(
{
"name": pool,
"status": stg_status,
"type": stg_type,
"autostart": stg.autostart(),
"type": util.get_xml_path(xml, "/pool/@type"),
"path": util.get_xml_path(xml, "/pool/target/path") or util.get_xml_path(xml, "/pool/source/name"),
"volumes": stg_vol,
"size": stg_size,
"free": stg_free,
"used_percent": (stg_size - stg_free) * 100 / stg_size if stg_size else 0,
}
)
return storages

View file

@ -195,7 +195,7 @@ class StoragesInfoTestCase(unittest.TestCase):
conn.get_storages = lambda: ["default"]
conn.get_storage = lambda name: pool
(info,) = conn.get_storages_info()
self.assertEqual((info["name"], info["size"], info["free"]), ("default", 500, 120))
self.assertEqual((info["name"], info["size"], info["free"], info["used_percent"]), ("default", 500, 120, 76))
# measured now: libvirt keeps a pool's space as of its last refresh
self.assertEqual([c[0] for c in pool.mock_calls[:2]], ["isActive", "refresh"])

View file

@ -27,7 +27,7 @@ class StatusContrastTests(TestCase):
return html.fromstring(response.content)
def test_card_headers_are_pale_with_dark_text(self):
for name in ("storages", "networks", "interfaces"):
for name in ("networks", "interfaces"):
with self.subTest(page=name):
headers = self.page(name).xpath("//div[contains(@class, 'card-header')]")
self.assertTrue(headers)