mirror of
https://github.com/retspen/webvirtcloud
synced 2026-11-09 19:56:48 +00:00
Deleting or detaching a disk, deleting a network card, forcing a VM off, reverting or deleting a snapshot, stopping or deleting a pool, a network, an interface, a filter or a secret, and the other actions that ask first opened the browser's yes/no box (confirm()), and the ISO upload told its mistakes in the browser's alert(). One dialog in the base page now asks for all of them: its title and confirming button carry the button's own words, its body the button's question, the confirming button red for a destructive action. Confirmed, the button is clicked again, so its form sends what it did before. A button inside another dialog (the flavors) hides that one while asking and shows it again after; Cancel gives the focus back, and the confirming button acts once. The ISO upload's mistakes show in its dialog's error box, announced.
365 lines
22 KiB
HTML
365 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 %}">{% 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 %}">{% 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 %}">{% 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 py-1 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 py-1 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 py-1 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 %}">{% 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="font-monospace text-break" 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="mb-3">
|
|
<label class="form-label" for="clone-name">{% trans "Name of the copy" %}</label>
|
|
<input type="text" class="form-control" name="name" id="clone-name" required pattern="[a-zA-Z0-9\.\-_]+" aria-describedby="clone-name-help">
|
|
<div class="form-text" id="clone-name-help">{% trans "Letters, digits, dot, dash and underscore." %}</div>
|
|
</div>
|
|
<div class="form-check">
|
|
<input class="form-check-input" type="checkbox" name="convert" value="true" id="clone-convert">
|
|
<label class="form-check-label" for="clone-convert">{% trans "Convert it to another format" %}</label>
|
|
</div>
|
|
<div class="mt-3 d-none" id="clone-format-row">
|
|
<label class="form-label" for="clone-format">{% trans "Format" %}</label>
|
|
<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 class="form-check mt-3 d-none" id="clone-prealloc-row">
|
|
<input class="form-check-input" type="checkbox" name="meta_prealloc" value="true" id="clone-prealloc">
|
|
<label class="form-check-label" for="clone-prealloc">{% trans "Preallocate metadata (qcow2)" %}</label>
|
|
</div>
|
|
</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">{% trans "Cancel" %}</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);
|
|
|
|
// a refused new volume: its dialog opens again on what was entered, at the refused field
|
|
var addImage = document.getElementById('AddImage');
|
|
if (addImage && addImage.hasAttribute('data-open')) {
|
|
addImage.addEventListener('shown.bs.modal', focusFirstError, {once: true});
|
|
bootstrap.Modal.getOrCreateInstance(addImage).show();
|
|
}
|
|
|
|
$(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];
|
|
var errorMessage = $('#upload-error-message');
|
|
if (fileInput.files.length === 0) {
|
|
errorMessage.text("{% trans 'Please select a file to upload.'|escapejs %}").removeClass("d-none");
|
|
return;
|
|
}
|
|
|
|
var file = fileInput.files[0];
|
|
var fileName = file.name;
|
|
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) {
|
|
errorMessage.text("{% trans 'The selected file is empty.'|escapejs %}").removeClass("d-none");
|
|
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 %}
|