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