1
0
Fork 0
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:
hudeng 2025-07-10 14:54:51 +08:00
parent c0f31909bd
commit 326579a6ea
4 changed files with 210 additions and 49 deletions

View file

@ -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

View file

@ -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 %}

View file

@ -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 %}

View file

@ -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():