From 3aa198304ab0f9a1e4b3eaf3d38e751a6a847d07 Mon Sep 17 00:00:00 2001 From: catborise Date: Wed, 28 Oct 2026 15:30:34 +0300 Subject: [PATCH] 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/) 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. --- computes/templates/computes/pool_bar.html | 3 +- computes/tests/test_overview.py | 3 +- computes/views.py | 2 - instances/templates/create_instance_w2.html | 4 - static/css/webvirtcloud.css | 17 + .../icon_cache/toggle-off_None_None_None.svg | 8 + .../icon_cache/toggle-on_None_None_None.svg | 8 + static/icon_cache/upload_None_None_None.svg | 11 + storages/forms.py | 82 +-- storages/templates/create_stg_block.html | 482 +++++++++--------- storages/templates/create_stg_vol_block.html | 16 +- storages/templates/storage.html | 198 ++++--- storages/templates/storages.html | 73 +-- storages/templates/storages/pool_kind.html | 7 + storages/tests/test_audit_rows.py | 4 +- storages/tests/test_new_pool.py | 129 +++++ storages/tests/test_pool_page.py | 39 ++ storages/tests/test_pool_target.py | 7 +- storages/views.py | 90 ++-- vrtManager/storage.py | 12 +- vrtManager/tests/test_host_reads.py | 2 +- webvirtcloud/tests/test_status_contrast.py | 2 +- 22 files changed, 729 insertions(+), 470 deletions(-) create mode 100644 static/icon_cache/toggle-off_None_None_None.svg create mode 100644 static/icon_cache/toggle-on_None_None_None.svg create mode 100644 static/icon_cache/upload_None_None_None.svg create mode 100644 storages/templates/storages/pool_kind.html create mode 100644 storages/tests/test_new_pool.py diff --git a/computes/templates/computes/pool_bar.html b/computes/templates/computes/pool_bar.html index ad873d01..1f63458e 100644 --- a/computes/templates/computes/pool_bar.html +++ b/computes/templates/computes/pool_bar.html @@ -1,5 +1,6 @@ {% load i18n %} -
{{ pool.name }}
+{# a pool's space; hide_name: the page names the pool next to it #} +{% if not hide_name %}
{{ pool.name }}
{% endif %}
diff --git a/computes/tests/test_overview.py b/computes/tests/test_overview.py index 3aaa67fe..31daac03 100644 --- a/computes/tests/test_overview.py +++ b/computes/tests/test_overview.py @@ -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): diff --git a/computes/views.py b/computes/views.py index 12d3c24b..3706456e 100644 --- a/computes/views.py +++ b/computes/views.py @@ -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:]} diff --git a/instances/templates/create_instance_w2.html b/instances/templates/create_instance_w2.html index 7f35cc87..23f066b6 100644 --- a/instances/templates/create_instance_w2.html +++ b/instances/templates/create_instance_w2.html @@ -13,10 +13,6 @@ {% endblock %} diff --git a/static/css/webvirtcloud.css b/static/css/webvirtcloud.css index 01855f3d..7c1265df 100644 --- a/static/css/webvirtcloud.css +++ b/static/css/webvirtcloud.css @@ -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; } diff --git a/static/icon_cache/toggle-off_None_None_None.svg b/static/icon_cache/toggle-off_None_None_None.svg new file mode 100644 index 00000000..b54ea042 --- /dev/null +++ b/static/icon_cache/toggle-off_None_None_None.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/static/icon_cache/toggle-on_None_None_None.svg b/static/icon_cache/toggle-on_None_None_None.svg new file mode 100644 index 00000000..9f9cb44e --- /dev/null +++ b/static/icon_cache/toggle-on_None_None_None.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/static/icon_cache/upload_None_None_None.svg b/static/icon_cache/upload_None_None_None.svg new file mode 100644 index 00000000..603bce64 --- /dev/null +++ b/static/icon_cache/upload_None_None_None.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/storages/forms.py b/storages/forms.py index 05e10729..f6692812 100644 --- a/storages/forms.py +++ b/storages/forms.py @@ -11,61 +11,79 @@ POOL_PATH = re.compile(r"(?!-)(?:[/0-9a-zA-Z_.:]|(? 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) diff --git a/storages/templates/create_stg_block.html b/storages/templates/create_stg_block.html index 51658437..71347b86 100644 --- a/storages/templates/create_stg_block.html +++ b/storages/templates/create_stg_block.html @@ -1,258 +1,248 @@ {% load i18n %} {% load bootstrap_icons %} +{% load form_fields %} {% if request.user.is_superuser %} - - - + - -