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 65c5a251ba style(computes,networks,storages,interfaces): the cards' rows tighter
The label and value rows of the host pages' cards (Overview, storage
pools, networks, interfaces) take Bootstrap's py-1 instead of the list
group's own padding, as the interface list's cards already do: about
half the height a row, the same on every page. A pool's space bar keeps
its padding.
2026-10-29 10:10:04 +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 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 %}"
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 %}