mirror of
https://github.com/retspen/webvirtcloud
synced 2025-07-31 12:41:08 +00:00
feat: Add ISO file upload progress support.
This commit is contained in:
parent
c0f31909bd
commit
326579a6ea
4 changed files with 210 additions and 49 deletions
|
@ -15,6 +15,7 @@ libvirt-python==11.4.0
|
||||||
lxml==6.0.0
|
lxml==6.0.0
|
||||||
ldap3==2.9.1
|
ldap3==2.9.1
|
||||||
markdown==3.8.2
|
markdown==3.8.2
|
||||||
|
paramiko==3.4.0
|
||||||
#psycopg2-binary
|
#psycopg2-binary
|
||||||
python-engineio==4.12.0
|
python-engineio==4.12.0
|
||||||
python-socketio==5.13.0
|
python-socketio==5.13.0
|
||||||
|
|
|
@ -16,20 +16,27 @@
|
||||||
<h5 class="modal-title">{% trans "Upload ISO Image" %}</h5>
|
<h5 class="modal-title">{% trans "Upload ISO Image" %}</h5>
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<form id="isoUploadForm" enctype="multipart/form-data" method="post" role="form" aria-label="Upload iso form">{% csrf_token %}
|
||||||
<form enctype="multipart/form-data" method="post" role="form" aria-label="Upload iso form">{% csrf_token %}
|
<div class="modal-body">
|
||||||
<div class="row">
|
<div class="row mb-3">
|
||||||
<label class="col-sm-3 col-form-label">{% trans "Name" %}</label>
|
<label for="id_file" class="col-sm-3 col-form-label">{% trans "Name" %}</label>
|
||||||
<div class="col-sm-6">
|
<div class="col-sm-9">
|
||||||
<input type="file" name="file" id="id_file">
|
<input type="file" name="file" id="id_file" class="form-control" required>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div id="upload-progress-container" class="mt-3" style="display: none;">
|
||||||
<div class="modal-footer">
|
<div class="progress">
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% trans "Close" %}</button>
|
<div id="upload-progress-bar" class="progress-bar" role="progressbar" style="width: 0%;" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100">0%</div>
|
||||||
<button type="submit" class="btn btn-primary" name="iso_upload">{% trans "Upload" %}</button>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
<div id="upload-error-message" class="alert alert-danger mt-3" style="display: none;"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<input type="hidden" name="iso_upload" value="true">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% trans "Close" %}</button>
|
||||||
|
<button type="submit" class="btn btn-primary">{% trans "Upload" %}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
</div> <!-- /.modal-content -->
|
</div> <!-- /.modal-content -->
|
||||||
</div> <!-- /.modal-dialog -->
|
</div> <!-- /.modal-dialog -->
|
||||||
|
@ -62,4 +69,4 @@
|
||||||
</div> <!-- /.modal -->
|
</div> <!-- /.modal -->
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
|
@ -58,32 +58,31 @@
|
||||||
<dd class="col-6">{{ used|filesizeformat }}</dd>
|
<dd class="col-6">{{ used|filesizeformat }}</dd>
|
||||||
<dt class="col-6">{% trans "State" %}</dt>
|
<dt class="col-6">{% trans "State" %}</dt>
|
||||||
<dd class="col-6">
|
<dd class="col-6">
|
||||||
<form action="" method="post" role="form" aria-label="Storage start/stop form">{% csrf_token %}
|
<form action="" method="post" role="form" aria-label="Storage start/stop form" class="confirm-form">{% csrf_token %}
|
||||||
{% if state == 0 %}
|
{% 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-secondary" name="start" value="{% trans "Start" %}">
|
||||||
<input type="submit" class="btn btn-sm btn-danger" name="delete" value="{% trans "Delete" %}"
|
<input type="submit" class="btn btn-sm btn-danger" name="delete" value="{% trans "Delete" %}">
|
||||||
onclick="return confirm('{% trans "Are you sure?" %}')">
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<input type="submit" class="btn btn-sm btn-secondary" name="stop" value="{% trans "Stop" %}"
|
<input type="submit" class="btn btn-sm btn-secondary" name="stop" value="{% trans "Stop" %}">
|
||||||
onclick="return confirm('{% trans "Are you sure?" %}')">
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</form>
|
</form>
|
||||||
</dd>
|
</dd>
|
||||||
<dt class="col-6">{% trans "Autostart" %}</dt>
|
<dt class="col-6">{% trans "Autostart" %}</dt>
|
||||||
<dd class="col-6">
|
<dd class="col-6">
|
||||||
<form action="" method="post" role="form" aria-label="Storage disable/enable autostart form">{% csrf_token %}
|
<form action="" method="post" role="form" aria-label="Storage disable/enable autostart form" class="confirm-form">{% csrf_token %}
|
||||||
{% if autostart == 0 %}
|
{% if autostart == 0 %}
|
||||||
<input type="submit" class="btn btn-sm btn-secondary" name="set_autostart"
|
<input type="submit" class="btn btn-sm btn-secondary" name="set_autostart"
|
||||||
value="{% trans "Enable" %}">
|
value="{% trans "Enable" %}">
|
||||||
{% else %}
|
{% else %}
|
||||||
<input type="submit" class="btn btn-sm btn-secondary" name="unset_autostart"
|
<input type="submit" class="btn btn-sm btn-secondary" name="unset_autostart"
|
||||||
onclick="return confirm('{% trans "Are you sure?" %}')" value="{% trans "Disable" %}">
|
value="{% trans "Disable" %}">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</form>
|
</form>
|
||||||
</dd>
|
</dd>
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
{% if state %}
|
{% if state %}
|
||||||
<p>
|
<p>
|
||||||
|
@ -171,9 +170,9 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<form action="" method="post" role="form" aria-label="Delete volume form">{% csrf_token %}
|
<form action="" method="post" role="form" aria-label="Delete volume form" class="confirm-form">{% csrf_token %}
|
||||||
<input type="hidden" name="volname" value="{{ volume.name }}">
|
<input type="hidden" name="volname" value="{{ volume.name }}">
|
||||||
<button type="submit" class="btn btn-sm btn-secondary" name="del_volume" title="{% trans "Delete" %}" onclick="return confirm('{% trans "Are you sure?" %}')">
|
<button type="submit" class="btn btn-sm btn-secondary" name="del_volume" title="{% trans "Delete" %}">
|
||||||
{% bs_icon 'trash' %}
|
{% bs_icon 'trash' %}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
@ -216,5 +215,80 @@
|
||||||
$('.meta-prealloc').hide();
|
$('.meta-prealloc').hide();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$(document).ready(function() {
|
||||||
|
$('.confirm-form').on('submit', function(e) {
|
||||||
|
if (!confirm('{% trans "Are you sure?"|escapejs %}')) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#isoUploadForm').on('submit', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
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 chunkSize = 10 * 1024 * 1024; // 10MB
|
||||||
|
var totalChunks = Math.ceil(file.size / chunkSize);
|
||||||
|
var chunkIndex = 0;
|
||||||
|
|
||||||
|
var progressBar = $('#upload-progress-bar');
|
||||||
|
var progressContainer = $('#upload-progress-container');
|
||||||
|
var errorMessage = $('#upload-error-message');
|
||||||
|
|
||||||
|
progressContainer.show();
|
||||||
|
progressBar.width('0%').attr('aria-valuenow', 0).text('0%');
|
||||||
|
errorMessage.hide();
|
||||||
|
|
||||||
|
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('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) {
|
||||||
|
chunkIndex++;
|
||||||
|
var percentComplete = Math.round((chunkIndex / totalChunks) * 100);
|
||||||
|
progressBar.width(percentComplete + '%').attr('aria-valuenow', percentComplete).text(percentComplete + '%');
|
||||||
|
|
||||||
|
if (data.reload) {
|
||||||
|
location.reload();
|
||||||
|
} else if (chunkIndex < totalChunks) {
|
||||||
|
uploadChunk();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: function(jqXHR, textStatus, errorThrown) {
|
||||||
|
var errorText = jqXHR.responseJSON && jqXHR.responseJSON.error ? jqXHR.responseJSON.error : "{% trans 'An error occurred during upload.'|escapejs %}";
|
||||||
|
errorMessage.text(errorText).show();
|
||||||
|
progressContainer.hide();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
uploadChunk();
|
||||||
|
});
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
@ -5,11 +5,14 @@ from admin.decorators import superuser_only
|
||||||
from appsettings.settings import app_settings
|
from appsettings.settings import app_settings
|
||||||
from computes.models import Compute
|
from computes.models import Compute
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.http import HttpResponse, HttpResponseRedirect
|
from django.http import HttpResponse, HttpResponseRedirect, JsonResponse
|
||||||
from django.shortcuts import get_object_or_404, redirect, render
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from libvirt import libvirtError
|
from libvirt import libvirtError
|
||||||
|
import paramiko
|
||||||
|
|
||||||
|
from vrtManager.connection import CONN_SSH, CONN_SOCKET
|
||||||
from vrtManager.storage import wvmStorage, wvmStorages
|
from vrtManager.storage import wvmStorage, wvmStorages
|
||||||
|
|
||||||
from storages.forms import AddStgPool, CloneImage, CreateVolumeForm
|
from storages.forms import AddStgPool, CloneImage, CreateVolumeForm
|
||||||
|
@ -102,20 +105,52 @@ def storage(request, compute_id, pool):
|
||||||
:param pool:
|
:param pool:
|
||||||
:return:
|
:return:
|
||||||
"""
|
"""
|
||||||
|
def handle_uploaded_file(conn, path, file_name, file_chunk, is_last_chunk):
|
||||||
|
temp_name = f"{file_name}.part"
|
||||||
|
target_temp = os.path.normpath(os.path.join(path, temp_name))
|
||||||
|
target_final = os.path.normpath(os.path.join(path, file_name))
|
||||||
|
|
||||||
def handle_uploaded_file(path, f_name):
|
if not target_temp.startswith(path) or not target_final.startswith(path):
|
||||||
target = os.path.normpath(os.path.join(path, str(f_name)))
|
|
||||||
if not target.startswith(path):
|
|
||||||
raise Exception(_("Security Issues with file uploading"))
|
raise Exception(_("Security Issues with file uploading"))
|
||||||
|
|
||||||
try:
|
if conn.conn == CONN_SSH:
|
||||||
with open(target, "wb+") as f:
|
try:
|
||||||
for chunk in f_name.chunks():
|
hostname, port = conn.host, 22
|
||||||
f.write(chunk)
|
if ":" in hostname:
|
||||||
except FileNotFoundError:
|
hostname, port_str = hostname.split(":")
|
||||||
messages.error(
|
port = int(port_str)
|
||||||
request, _("File not found. Check the path variable and filename")
|
|
||||||
)
|
ssh = paramiko.SSHClient()
|
||||||
|
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
ssh.connect(hostname=hostname, port=port, username=conn.login, password=conn.passwd)
|
||||||
|
sftp = ssh.open_sftp()
|
||||||
|
|
||||||
|
remote_file = sftp.open(target_temp, 'ab')
|
||||||
|
remote_file.set_pipelined(True)
|
||||||
|
for chunk_data in file_chunk.chunks():
|
||||||
|
remote_file.write(chunk_data)
|
||||||
|
remote_file.close()
|
||||||
|
|
||||||
|
if is_last_chunk:
|
||||||
|
sftp.rename(target_temp, target_final)
|
||||||
|
|
||||||
|
sftp.close()
|
||||||
|
ssh.close()
|
||||||
|
except Exception as e:
|
||||||
|
raise Exception(_("SSH upload failed: {}").format(e))
|
||||||
|
elif conn.conn == CONN_SOCKET:
|
||||||
|
try:
|
||||||
|
with open(target_temp, "ab") as f:
|
||||||
|
for chunk_data in file_chunk.chunks():
|
||||||
|
f.write(chunk_data)
|
||||||
|
if is_last_chunk:
|
||||||
|
if os.path.exists(target_final):
|
||||||
|
os.remove(target_final)
|
||||||
|
os.rename(target_temp, target_final)
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise Exception(_("File not found. Check the path variable and filename"))
|
||||||
|
else:
|
||||||
|
raise Exception(_("Unsupported connection type for file upload."))
|
||||||
|
|
||||||
compute = get_object_or_404(Compute, pk=compute_id)
|
compute = get_object_or_404(Compute, pk=compute_id)
|
||||||
meta_prealloc = False
|
meta_prealloc = False
|
||||||
|
@ -127,12 +162,16 @@ def storage(request, compute_id, pool):
|
||||||
|
|
||||||
storages = conn.get_storages()
|
storages = conn.get_storages()
|
||||||
state = conn.is_active()
|
state = conn.is_active()
|
||||||
size, free = conn.get_size()
|
try:
|
||||||
used = size - free
|
size, free = conn.get_size()
|
||||||
if state:
|
used = size - free
|
||||||
percent = (used * 100) // size
|
if state:
|
||||||
else:
|
percent = (used * 100) // size
|
||||||
percent = 0
|
else:
|
||||||
|
percent = 0
|
||||||
|
except libvirtError:
|
||||||
|
size, free, used, percent = 0, 0, 0, 0
|
||||||
|
|
||||||
status = conn.get_status()
|
status = conn.get_status()
|
||||||
path = conn.get_target_path()
|
path = conn.get_target_path()
|
||||||
type = conn.get_type()
|
type = conn.get_type()
|
||||||
|
@ -170,16 +209,56 @@ def storage(request, compute_id, pool):
|
||||||
return redirect(reverse("storage", args=[compute.id, pool]))
|
return redirect(reverse("storage", args=[compute.id, pool]))
|
||||||
# return HttpResponseRedirect(request.get_full_path())
|
# return HttpResponseRedirect(request.get_full_path())
|
||||||
if "iso_upload" in request.POST:
|
if "iso_upload" in request.POST:
|
||||||
if str(request.FILES["file"]) in conn.update_volumes():
|
file_chunk = request.FILES.get("file")
|
||||||
error_msg = _("ISO image already exist")
|
if not file_chunk:
|
||||||
|
return JsonResponse({"error": _("No file chunk was submitted.")}, status=400)
|
||||||
|
|
||||||
|
file_name = request.POST.get("file_name")
|
||||||
|
chunk_index = int(request.POST.get("chunk_index", 0))
|
||||||
|
total_chunks = int(request.POST.get("total_chunks", 1))
|
||||||
|
is_last_chunk = chunk_index == total_chunks - 1
|
||||||
|
|
||||||
|
# On first chunk, check if file already exists
|
||||||
|
if chunk_index == 0:
|
||||||
|
if file_name in conn.get_volumes():
|
||||||
|
return JsonResponse({"error": _("ISO image already exists")}, status=400)
|
||||||
|
# Clean up any partial files from previous failed uploads
|
||||||
|
temp_part_file = os.path.normpath(os.path.join(path, f"{file_name}.part"))
|
||||||
|
if conn.conn == CONN_SOCKET and os.path.exists(temp_part_file):
|
||||||
|
os.remove(temp_part_file)
|
||||||
|
elif conn.conn == CONN_SSH:
|
||||||
|
try:
|
||||||
|
hostname, port = conn.host, 22
|
||||||
|
if ":" in hostname:
|
||||||
|
hostname, port_str = hostname.split(":")
|
||||||
|
port = int(port_str)
|
||||||
|
ssh = paramiko.SSHClient()
|
||||||
|
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
ssh.connect(hostname=hostname, port=port, username=conn.login, password=conn.passwd)
|
||||||
|
sftp = ssh.open_sftp()
|
||||||
|
try:
|
||||||
|
sftp.remove(temp_part_file)
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass # File doesn't exist, which is fine
|
||||||
|
sftp.close()
|
||||||
|
ssh.close()
|
||||||
|
except Exception:
|
||||||
|
# Best effort to clean up, if it fails, let it be.
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
handle_uploaded_file(conn, path, file_name, file_chunk, is_last_chunk)
|
||||||
|
|
||||||
|
if is_last_chunk:
|
||||||
|
success_msg = _("ISO: %(file)s has been uploaded successfully.") % {"file": file_name}
|
||||||
|
messages.success(request, success_msg)
|
||||||
|
return JsonResponse({"success": True, "message": success_msg, "reload": True})
|
||||||
|
else:
|
||||||
|
return JsonResponse({"success": True, "message": "Chunk received."})
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = str(e)
|
||||||
messages.error(request, error_msg)
|
messages.error(request, error_msg)
|
||||||
else:
|
return JsonResponse({"error": error_msg}, status=500)
|
||||||
handle_uploaded_file(path, request.FILES["file"])
|
|
||||||
messages.success(
|
|
||||||
request,
|
|
||||||
_("ISO: %(file)s is uploaded.") % {"file": request.FILES["file"]},
|
|
||||||
)
|
|
||||||
return HttpResponseRedirect(request.get_full_path())
|
|
||||||
if "cln_volume" in request.POST:
|
if "cln_volume" in request.POST:
|
||||||
form = CloneImage(request.POST)
|
form = CloneImage(request.POST)
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue