1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-09 19:56:48 +00:00
webvirtcloud/storages/templates/storage.html
catborise 3aa198304a 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.
2026-10-28 19:27:21 +03:00

369 lines
22 KiB
HTML

{% extends "base.html" %}
{% load i18n %}
{% load operations %}
{% load static %}
{% load bootstrap_icons %}
{% block title %}{{ compute }} - {% trans "Storage" %}: {{ pool }}{% endblock %}
{% block page_heading %}{{ compute }} - {% trans "Storage" %}: {{ pool }}{% endblock page_heading %}
{% 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>
<div class="row">
<div class="col-lg-12">
{% include "computes/host_nav.html" with section="storages" detail=True %}
</div>
</div>
<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 %}
</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="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">{% trans "Format" %}</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><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 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 %}
</tbody>
</table>
</div>
{# a live region from the start: screen readers hear the message when the search empties the table #}
<div role="status">
<div id="no-match" class="text-center text-body-secondary py-4" hidden>{% trans "No volume matches the search." %}</div>
</div>
{# one dialog for every volume: the button that opens it names the volume #}
<div class="modal fade" id="CloneVolume" tabindex="-1" aria-labelledby="CloneVolumeLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="CloneVolumeLabel">{% trans "Clone image" %} <span class="text-danger" id="clone-image-name"></span></h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% trans "Close" %}"></button>
</div>
<form method="post" aria-labelledby="CloneVolumeLabel">{% csrf_token %}{% submission_key %}
<div class="modal-body">
<input type="hidden" name="image" id="clone-image">
<div class="row mb-3">
<label class="col-sm-3 col-form-label" for="clone-name">{% trans "Name" %}</label>
<div class="col-sm-6">
<input type="text" class="form-control" name="name" id="clone-name" required pattern="[a-zA-Z0-9\.\-_]+">
</div>
</div>
<div class="row mb-3">
<label class="col-sm-3 col-form-label" for="clone-convert">{% trans "Convert" %}</label>
<div class="col-sm-6">
<input class="form-check-input mt-2" type="checkbox" name="convert" value="true" id="clone-convert">
</div>
</div>
<div class="row mb-3 d-none" id="clone-format-row">
<label class="col-sm-3 col-form-label" for="clone-format">{% trans "Format" %}</label>
<div class="col-sm-6">
<select name="format" class="form-select" id="clone-format">
<option value="raw">{% trans "raw" %}</option>
<option value="qcow">{% trans "qcow" %}</option>
<option value="qcow2">{% trans "qcow2" %}</option>
</select>
</div>
</div>
<div class="row d-none" id="clone-prealloc-row">
<label class="col-sm-3 col-form-label" for="clone-prealloc">{% trans "Metadata" %}</label>
<div class="col-sm-6">
<input class="form-check-input mt-2" type="checkbox" name="meta_prealloc" value="true" id="clone-prealloc">
</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_volume">{% trans "Clone" %}</button>
</div>
</form>
</div>
</div>
</div>
{% else %}
<div class="text-center text-body-secondary py-4">{% trans "This pool has no volumes." %}</div>
{% endif %}
{% endblock %}
{% block script %}
<script src="{% static 'js/poll.js' %}"></script>
{% job_panel_script job_resource %}
{% if volumes %}<script src="{% static 'js/table_filter.js' %}"></script>{% endif %}
<script>
// the clone dialog: filled for the volume whose button opened it, its
// choices reset; the format rows show while converting
function showCloneRows() {
var convert = $('#clone-convert').prop('checked');
$('#clone-format-row').toggleClass('d-none', !convert);
$('#clone-prealloc-row').toggleClass('d-none', !(convert && $('#clone-format').val() == 'qcow2'));
}
$('#CloneVolume').on('show.bs.modal', function (event) {
var volume = event.relatedTarget.dataset.volume;
$('#clone-image').val(volume);
$('#clone-image-name').text(volume);
$('#clone-name').val('');
$('#clone-convert, #clone-prealloc').prop('checked', false);
$('#clone-format').val('raw');
showCloneRows();
});
$('#clone-convert, #clone-format').on('change', showCloneRows);
$(document).ready(function() {
var pendingUpload = null;
var uploadInProgress = false;
// the browser sends the file: leaving the page stops the upload
window.addEventListener('beforeunload', function(e) {
if (uploadInProgress) { e.preventDefault(); e.returnValue = ''; }
});
$('#isoUploadForm').on('submit', function(e) {
e.preventDefault();
if (uploadInProgress) return;
var fileInput = $('#id_file')[0];
if (fileInput.files.length === 0) {
alert("{% trans 'Please select a file to upload.'|escapejs %}");
return;
}
var file = fileInput.files[0];
var fileName = file.name;
var errorMessage = $('#upload-error-message');
if (!/\.iso$/i.test(fileName) || fileName.length <= 4) {
errorMessage.text("{% trans 'Only .iso files can be uploaded.'|escapejs %}").removeClass("d-none");
return;
}
if (file.size === 0) {
alert("{% trans 'The selected file is empty.'|escapejs %}");
return;
}
var chunkSize = 32 * 1024 * 1024; // Fewer SSH connections per upload
var totalChunks = Math.ceil(file.size / chunkSize);
var fileSignature = [fileName, file.size, file.lastModified].join(':');
if (!pendingUpload || pendingUpload.signature !== fileSignature) {
pendingUpload = {
signature: fileSignature,
chunkIndex: 0,
uploadId: (window.crypto && window.crypto.randomUUID) ? window.crypto.randomUUID() : (Date.now() + '-' + Math.random().toString(36).substring(2, 10))
};
}
var chunkIndex = pendingUpload.chunkIndex;
var uploadId = pendingUpload.uploadId;
var retryCount = 0;
uploadInProgress = true;
var uploadDialog = bootstrap.Modal.getOrCreateInstance(document.getElementById('IsoUpload'));
var uploadStatus = $('#upload-status');
var uploadStatusText = $('#upload-status-text');
var uploadButton = $('#isoUploadForm button[type="submit"]');
errorMessage.addClass("d-none");
uploadStatusText.text("{% trans 'Starting upload...'|escapejs %}");
uploadStatus.removeClass('d-none').addClass('d-flex');
uploadButton.prop('disabled', true);
function uploadChunk() {
if (chunkIndex >= totalChunks) {
return;
}
var start = chunkIndex * chunkSize;
var end = Math.min(start + chunkSize, file.size);
var chunk = file.slice(start, end);
var formData = new FormData();
formData.append('file', chunk, fileName);
formData.append('file_name', fileName);
formData.append('chunk_index', chunkIndex);
formData.append('total_chunks', totalChunks);
formData.append('file_size', file.size);
formData.append('upload_id', uploadId);
formData.append('iso_upload', 'true');
formData.append('csrfmiddlewaretoken', '{{ csrf_token }}');
$.ajax({
url: "{% url 'storage' compute.id pool %}",
type: 'POST',
data: formData,
processData: false,
contentType: false,
success: function(data) {
if (chunkIndex === 0) {
// the job panel follows the upload from here
window.resourceJobPanel.track(data.job);
uploadStatusText.text("{% trans 'Uploading; the job panel shows its progress. Stay on this page until it ends.'|escapejs %}");
uploadDialog.hide();
}
retryCount = 0;
chunkIndex++;
pendingUpload.chunkIndex = chunkIndex;
if (chunkIndex < totalChunks) {
uploadChunk();
} else {
pendingUpload = null;
uploadInProgress = false;
uploadStatus.removeClass('d-flex').addClass('d-none');
uploadButton.prop('disabled', false);
}
},
error: function(jqXHR, textStatus, errorThrown) {
if ((jqXHR.status === 0 || jqXHR.status === 429 || jqXHR.status >= 500) && retryCount < 3) {
retryCount++;
uploadStatusText.text("{% trans 'Retrying upload...'|escapejs %}");
setTimeout(uploadChunk, 1000 * Math.pow(2, retryCount - 1));
return;
}
uploadInProgress = false;
uploadStatus.removeClass('d-flex').addClass('d-none');
uploadButton.prop('disabled', false);
if (jqXHR.status >= 400 && jqXHR.status < 500 && jqXHR.status !== 429) {
pendingUpload = null;
}
var errorText = jqXHR.responseJSON && jqXHR.responseJSON.error ? jqXHR.responseJSON.error : "{% trans 'An error occurred during upload.'|escapejs %}";
errorMessage.text(errorText).removeClass("d-none");
uploadDialog.show();
}
});
}
uploadChunk();
});
});
</script>
{% endblock %}