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 | ||||
| ldap3==2.9.1 | ||||
| markdown==3.8.2 | ||||
| paramiko==3.4.0 | ||||
| #psycopg2-binary | ||||
| python-engineio==4.12.0 | ||||
| python-socketio==5.13.0 | ||||
|  |  | |||
|  | @ -16,18 +16,25 @@ | |||
|                             <h5 class="modal-title">{% trans "Upload ISO Image" %}</h5> | ||||
|                             <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> | ||||
|                         </div> | ||||
|                         <form id="isoUploadForm" enctype="multipart/form-data" method="post" role="form" aria-label="Upload iso form">{% csrf_token %} | ||||
|                             <div class="modal-body"> | ||||
|                             <form enctype="multipart/form-data" method="post" role="form" aria-label="Upload iso form">{% csrf_token %} | ||||
|                                 <div class="row"> | ||||
|                                     <label class="col-sm-3 col-form-label">{% trans "Name" %}</label> | ||||
|                                     <div class="col-sm-6"> | ||||
|                                         <input type="file" name="file" id="id_file"> | ||||
|                                 <div class="row mb-3"> | ||||
|                                     <label for="id_file" class="col-sm-3 col-form-label">{% trans "Name" %}</label> | ||||
|                                     <div class="col-sm-9"> | ||||
|                                         <input type="file" name="file" id="id_file" class="form-control" required> | ||||
|                                     </div> | ||||
|                                 </div> | ||||
|                                 <div id="upload-progress-container" class="mt-3" style="display: none;"> | ||||
|                                     <div class="progress"> | ||||
|                                         <div id="upload-progress-bar" class="progress-bar" role="progressbar" style="width: 0%;" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100">0%</div> | ||||
|                                     </div> | ||||
|                                 </div> | ||||
|                                 <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" name="iso_upload">{% trans "Upload" %}</button> | ||||
|                                 <button type="submit" class="btn btn-primary">{% trans "Upload" %}</button> | ||||
|                             </div> | ||||
|                         </form> | ||||
| 
 | ||||
|  |  | |||
|  | @ -58,26 +58,24 @@ | |||
|                 <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">{% csrf_token %} | ||||
|                     <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" %}" | ||||
|                                     onclick="return confirm('{% trans "Are you sure?" %}')"> | ||||
|                             <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" %}" | ||||
|                                     onclick="return confirm('{% trans "Are you sure?" %}')"> | ||||
|                             <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">{% csrf_token %} | ||||
|                     <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" | ||||
|                                 onclick="return confirm('{% trans "Are you sure?" %}')" value="{% trans "Disable" %}"> | ||||
|                                 value="{% trans "Disable" %}"> | ||||
|                     {% endif %} | ||||
|                 </form> | ||||
|                 </dd> | ||||
|  | @ -85,6 +83,7 @@ | |||
|         </div> | ||||
|     </div> | ||||
| 
 | ||||
|      | ||||
|     {% if state %} | ||||
|         <p> | ||||
|             {% include 'search_block.html' %} | ||||
|  | @ -171,9 +170,9 @@ | |||
|                         {% endif %} | ||||
|                     </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 }}"> | ||||
|                             <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' %}  | ||||
|                             </button> | ||||
|                         </form> | ||||
|  | @ -216,5 +215,80 @@ | |||
|                 $('.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> | ||||
| {% endblock %} | ||||
|  |  | |||
|  | @ -5,11 +5,14 @@ from admin.decorators import superuser_only | |||
| from appsettings.settings import app_settings | ||||
| from computes.models import Compute | ||||
| 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.urls import reverse | ||||
| from django.utils.translation import gettext_lazy as _ | ||||
| from libvirt import libvirtError | ||||
| import paramiko | ||||
| 
 | ||||
| from vrtManager.connection import CONN_SSH, CONN_SOCKET | ||||
| from vrtManager.storage import wvmStorage, wvmStorages | ||||
| 
 | ||||
| from storages.forms import AddStgPool, CloneImage, CreateVolumeForm | ||||
|  | @ -102,20 +105,52 @@ def storage(request, compute_id, pool): | |||
|     :param pool: | ||||
|     :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): | ||||
|         target = os.path.normpath(os.path.join(path, str(f_name))) | ||||
|         if not target.startswith(path): | ||||
|         if not target_temp.startswith(path) or not target_final.startswith(path): | ||||
|             raise Exception(_("Security Issues with file uploading")) | ||||
| 
 | ||||
|         if conn.conn == CONN_SSH: | ||||
|             try: | ||||
|             with open(target, "wb+") as f: | ||||
|                 for chunk in f_name.chunks(): | ||||
|                     f.write(chunk) | ||||
|                 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() | ||||
| 
 | ||||
|                 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: | ||||
|             messages.error( | ||||
|                 request, _("File not found. Check the path variable and filename") | ||||
|             ) | ||||
|                 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) | ||||
|     meta_prealloc = False | ||||
|  | @ -127,12 +162,16 @@ def storage(request, compute_id, pool): | |||
| 
 | ||||
|     storages = conn.get_storages() | ||||
|     state = conn.is_active() | ||||
|     try: | ||||
|         size, free = conn.get_size() | ||||
|         used = size - free | ||||
|         if state: | ||||
|             percent = (used * 100) // size | ||||
|         else: | ||||
|             percent = 0 | ||||
|     except libvirtError: | ||||
|         size, free, used, percent = 0, 0, 0, 0 | ||||
| 
 | ||||
|     status = conn.get_status() | ||||
|     path = conn.get_target_path() | ||||
|     type = conn.get_type() | ||||
|  | @ -170,16 +209,56 @@ def storage(request, compute_id, pool): | |||
|             return redirect(reverse("storage", args=[compute.id, pool])) | ||||
|             # return HttpResponseRedirect(request.get_full_path()) | ||||
|         if "iso_upload" in request.POST: | ||||
|             if str(request.FILES["file"]) in conn.update_volumes(): | ||||
|                 error_msg = _("ISO image already exist") | ||||
|                 messages.error(request, error_msg) | ||||
|             file_chunk = request.FILES.get("file") | ||||
|             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: | ||||
|                 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()) | ||||
|                     return JsonResponse({"success": True, "message": "Chunk received."}) | ||||
|             except Exception as e: | ||||
|                 error_msg = str(e) | ||||
|                 messages.error(request, error_msg) | ||||
|                 return JsonResponse({"error": error_msg}, status=500) | ||||
|         if "cln_volume" in request.POST: | ||||
|             form = CloneImage(request.POST) | ||||
|             if form.is_valid(): | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue