mirror of
https://github.com/retspen/webvirtcloud
synced 2026-11-09 19:56:48 +00:00
Twenty tables had no .table-responsive wrapper, so on a phone the user list, compute list, VM lists, network and network filter pages scrolled sideways as a whole. Each table now scrolls inside its box, as the other ten already did. The page test walks the network page too, and checks that every table on every page has the wrapper. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
350 lines
19 KiB
HTML
350 lines
19 KiB
HTML
{% extends "base.html" %}
|
|
{% load i18n %}
|
|
{% load operations %}
|
|
{% load static %}
|
|
{% load bootstrap_icons %}
|
|
{% block title %}{{ compute }} - {% trans "Storage" %}: {{ pool }}{% endblock %}
|
|
{% block style %}
|
|
<link rel="stylesheet" href="{% static "css/sortable-theme-bootstrap.css" %}"/>
|
|
{% 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" class="confirm-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" %}">
|
|
{% else %}
|
|
<input type="submit" class="btn btn-sm btn-secondary" name="stop" value="{% trans "Stop" %}">
|
|
{% 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" class="confirm-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" %}">
|
|
{% endif %}
|
|
</form>
|
|
</dd>
|
|
</dl>
|
|
</div>
|
|
</div>
|
|
|
|
|
|
{% if state %}
|
|
<div>
|
|
{% include 'search_block.html' %}
|
|
<h5 class="page-header">{% trans "Volumes" %}</h5>
|
|
</div>
|
|
{% if volumes %}
|
|
<div class="table-responsive">
|
|
<table class="table table-hover sortable-theme-bootstrap" data-sortable>
|
|
<thead>
|
|
<tr>
|
|
<th scope="col">#</th>
|
|
<th scope="col">{% trans "Name" %}</th>
|
|
<th scope="col">{% trans "Allocated" %}</th>
|
|
<th scope="col">{% trans "Size" %}</th>
|
|
<th scope="col">{% trans "Format" %}</th>
|
|
<th scope="colgroup" data-sortable="false" 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>{{ volume.allocation|filesizeformat }}</td>
|
|
<td>{{ 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" class="confirm-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" %}">
|
|
{% 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/sortable.min.js' %}"></script>
|
|
<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() {
|
|
$('.confirm-form').on('submit', function(e) {
|
|
if (!confirm('{% trans "Are you sure?"|escapejs %}')) {
|
|
e.preventDefault();
|
|
}
|
|
});
|
|
|
|
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 %}
|