1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-11 12:46:49 +00:00
webvirtcloud/storages/templates/storage.html
catborise fa5a8c4d6e fix(storages): the pool page measures its pool fresh, shares one clone dialog and says what it cannot show
- The page lists the volumes first: that refreshes the pool, and libvirt
  measures a pool's space only when it is refreshed, so the size and
  usage shown were as of the previous refresh.
- A space that cannot be read says so instead of 0 bytes, and the
  pool's status, read from the same pool info, no longer fails the
  page after it; a stopped pool says its space and volumes are
  unavailable until it is started, instead of 0 bytes and nothing.
- The API's pool is measured after the same refresh, and a stopped
  pool lists no volumes there instead of failing.
- A clone the job refuses (a name in use, a volume gone) is a message
  on the page, not a server error.
- An ISO image is recognised by its name (storages.jobs.is_iso):
  libvirt calls its format raw, so the template's "iso" check never
  disabled its Clone button. The clone job refuses one too; the upload
  uses the same rule.
- One clone dialog serves every volume, filled and reset by the button
  that opens it, instead of one dialog per volume in the page; its
  fields have labels and its title names the dialog.
- Volume rows are searched by name only (data-search), with a message
  when none matches, in a live region for screen readers; names show
  in full. The search script loads only
  with the volume table: on a stopped pool's page it failed for want
  of a search box. An empty pool says "This pool has no volumes".
- The unused wvmStorage.get_volumes_details goes: update_volumes is
  the same.
2026-10-28 19:20:35 +03:00

327 lines
18 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 %}{% include 'create_stg_vol_block.html' %}{% 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">
<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)">
{% endif %}
</form>
</dd>
</dl>
</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>
<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>
</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>
<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>
</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="alert alert-warning shadow-sm">
{% bs_icon 'exclamation-triangle' %} {% 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 %}