1
0
Fork 0
mirror of https://github.com/retspen/webvirtcloud synced 2026-11-09 03:36:48 +00:00
webvirtcloud/storages/templates/storage.html
catborise 7cf12b997b feat(tables): tables sort with tablesort, the grouped VM list within each host
sortable.js 0.8.0 (abandoned since 2014) sorted only a table's first
body: in the grouped VM list the first host's row moved among its VMs
and the other hosts never sorted, and memory sorted as text ("1024 MB"
before "512 MB"). tablesort 5.7.1 sorts each table body on its own and
keeps a row marked data-sort-method="none" in place, so hosts stay in
name order with their row on top and their VMs sort below it. Memory,
vCPUs and volume sizes sort by their numbers (data-sort). Headers are
reachable by keyboard and carry aria-sort.

base.html loads tablesort for every table marked data-sortable; the
per-page sortable includes and sortable-theme-bootstrap.css go, so these
tables take the theme's table style like the others. The grouped list's
row numbers go (meaningless once sorted). The VM owners and VM logs
tables were marked sortable but never loaded the script; they stay
unsorted.
2026-10-24 19:02:51 +03:00

344 lines
20 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">
<ol class="breadcrumb shadow-sm">
<li class="breadcrumb-item">
<a href="{% url 'overview' compute.id %}">{% bs_icon 'laptop' %} {% trans "Overview" %}</a>
</li>
<li class="breadcrumb-item">
<a href="{% url 'instances' compute.id %}">{% bs_icon 'server' %} {% trans "Instances" %}</a>
</li>
<li class="breadcrumb-item">
<a href="{% url 'storages' compute.id %}">{% bs_icon 'device-hdd' %} {% trans "Storages" %}</a>
</li>
<li class="breadcrumb-item">
<a href="{% url 'networks' compute.id %}">{% bs_icon 'hdd-network' %} {% trans "Networks" %}</a>
</li>
<li class="breadcrumb-item">
<a href="{% url 'interfaces' compute.id %}">{% bs_icon 'wifi' %} {% trans "Interfaces" %}</a>
</li>
<li class="breadcrumb-item">
<a href="{% url 'nwfilters' compute.id %}">{% bs_icon 'filter' %} {% trans "NWFilters" %}</a>
</li>
<li class="breadcrumb-item">
<a href="{% url 'virtsecrets' compute.id %}">{% bs_icon 'key' %} {% trans "Secrets" %}</a>
</li>
</ol>
</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>
<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>
<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 state %}
<div class="clearfix">
{% include 'search_block.html' %}
<h5 class="page-header">{% trans "Volumes" %}</h5>
</div>
{% if volumes %}
<div class="table-responsive">
<table class="table table-hover" data-sortable>
<thead>
<tr>
<th scope="col" data-sort-method="number">#</th>
<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>
<td>{{ forloop.counter }}</td>
<td>{{ volume.name|truncatechars:55 }}</td>
<td data-sort="{{ volume.allocation }}">{{ volume.allocation|filesizeformat }}</td>
<td data-sort="{{ volume.size }}">{{ volume.size|filesizeformat }}</td>
<td>{{ volume.type }}</td>
<td>
<!-- Modal Clone -->
<div class="modal fade" id="Clone{{ forloop.counter }}" tabindex="-1" role="dialog"
aria-labelledby="addHostLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">{% trans "Clone image" %} <span class="text-danger">{{ volume.name }}</span></h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form method="post" role="form" aria-label="Clone image file form">{% csrf_token %}{% submission_key %}
<div class="modal-body">
<div class="row">
<label class="col-sm-3 col-form-label">{% trans "Name" %}</label>
<div class="col-sm-6">
<input type="text" class="form-control" name="name" placeholder="{% trans "Name" %}" required pattern="[a-zA-Z0-9\.\-_]+">
<input type="hidden" name="image" value="{{ volume.name }}">
</div>
</div>
<div class="row" id="image_format">
<label class="col-sm-3 col-form-label" for="clone{{ forloop.counter }}_convert">{% trans "Convert" %}</label>
<div class="col-sm-6">
<input class="volume-convert" type="checkbox" name="convert" value="true" id="clone{{ forloop.counter }}_convert">
</div>
</div>
<div class="row format-convert">
<label class="col-sm-3 col-form-label">{% trans "Format" %}</label>
<div class="col-sm-6">
<select name="format" class="form-select image-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 meta-prealloc" style="display: none;">
<label class="col-sm-3 col-form-label">{% trans "Metadata" %}</label>
<div class="col-sm-6">
<input type="checkbox" name="meta_prealloc" value="true">
</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> <!-- /.modal-content -->
</div> <!-- /.modal-dialog -->
</div> <!-- /.modal -->
{% if volume.type != "iso" %}
<button class="btn btn-sm btn-secondary" data-bs-toggle="modal" data-bs-target="#Clone{{ forloop.counter }}" title="{% trans "Clone" %}">
{% bs_icon 'files' %}
</button>
{% else %}
<button class="btn btn-sm btn-secondary disabled">
{% bs_icon 'files' %}
</button>
{% endif %}
</td>
<td>
<form action="" method="post" role="form" aria-label="Delete volume form">{% 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" %}"
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>
{% else %}
<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 Volumes" %}
</div>
</div>
{% endif %}
{% endif %}
{% endblock %}
{% block script %}
<script src="{% static 'js/poll.js' %}"></script>
{% job_panel_script job_resource %}
<script src="{% static 'js/filter-table.js' %}"></script>
<script>
$('.format-convert').hide();
$(document).on('change', '.volume-convert', function () {
if ($(this).prop('checked')) {
$('.format-convert').show();
if ($('.image-format').val() == 'qcow2') {
$('.meta-prealloc').show();
}
} else {
$('.format-convert').hide();
$('.meta-prealloc').hide();
}
});
$(document).on('change', '.image-format', function () {
if ($(this).val() == "qcow2") {
$('.meta-prealloc').show();
} else {
$('.meta-prealloc').hide();
}
});
$(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 %}").show();
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.hide();
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).show();
uploadDialog.show();
}
});
}
uploadChunk();
});
});
</script>
{% endblock %}