diff --git a/app.py b/app.py index 9c9a4e8..704452e 100644 --- a/app.py +++ b/app.py @@ -2568,6 +2568,400 @@ def api_delete_lxc_template(node, volid): return jsonify({"error": f"Failed to delete template: {str(e)}"}), 500 +@app.route("/api/node//available-lxc-templates") +def api_available_lxc_templates(node): + """API endpoint to get available LXC appliance templates from Proxmox repo""" + proxmox = get_proxmox_connection(node, auto_renew=True) + if not proxmox: + return jsonify({"error": "Node not found"}), 404 + + try: + # Get available appliance templates from Proxmox repository + templates = proxmox.nodes(node).aplinfo.get() + + # Group templates by section/category + grouped = {} + for template in templates: + section = template.get("section", "other") + if section not in grouped: + grouped[section] = [] + + grouped[section].append( + { + "template": template.get("template", ""), + "headline": template.get("headline", ""), + "description": template.get("description", ""), + "os": template.get("os", ""), + "version": template.get("version", ""), + "package": template.get("package", ""), + "source": template.get("source", ""), + "sha512sum": template.get("sha512sum", ""), + "infopage": template.get("infopage", ""), + } + ) + + # Sort templates within each section by template name + for section in grouped: + grouped[section].sort(key=lambda x: x["template"]) + + return jsonify({"templates": grouped, "total": len(templates)}) + + except Exception as e: + return jsonify({"error": f"Failed to get available templates: {str(e)}"}), 500 + + +def run_lxc_template_download_job(job_id, node, params): + """Background job to download LXC container template and track progress""" + job_queue.set_running(job_id) + + try: + proxmox = get_proxmox_connection(node, auto_renew=True) + if not proxmox: + job_queue.set_failed(job_id, "Failed to connect to node") + return + + source_type = params.get("source_type", "proxmox") + storage = params.get("storage") + template_name = params.get("template_name", "template") + + job_queue.add_step(job_id, f"Starting {source_type} download...") + job_queue.update_job(job_id, progress=5) + + task_upid = None + + if source_type == "proxmox": + template = params.get("template") + job_queue.add_step(job_id, f"Downloading template: {template}") + job_queue.update_job(job_id, progress=10) + + # Download from Proxmox appliance repository + task_upid = proxmox.nodes(node).aplinfo.post( + storage=storage, template=template + ) + job_queue.add_step(job_id, f"Download task started: {task_upid}") + + elif source_type == "url": + url = params.get("url") + filename = params.get("filename") + job_queue.add_step(job_id, f"Downloading from URL: {url}") + job_queue.update_job(job_id, progress=10) + + # Download from URL + task_upid = ( + proxmox.nodes(node) + .storage(storage)("download-url") + .post(url=url, filename=filename, content="vztmpl") + ) + job_queue.add_step(job_id, f"Download task started: {task_upid}") + + elif source_type == "oci": + image = params.get("image") + filename = params.get("filename") + # Remove docker:// prefix for the API call + image_ref = ( + image.replace("docker://", "") + if image.startswith("docker://") + else image + ) + job_queue.add_step(job_id, f"Node: {node}, Storage: {storage}") + job_queue.add_step(job_id, f"Downloading OCI image: {image_ref}") + job_queue.add_step(job_id, f"Output filename: {filename}") + job_queue.update_job(job_id, progress=10) + + # OCI images are downloaded via oci-registry-pull endpoint (Proxmox 8+) + # Use direct path construction for proxmoxer compatibility + # API requires 'reference' parameter (not 'image') + try: + storage_api = proxmox.nodes(node).storage(storage) + task_upid = storage_api.post( + "oci-registry-pull", reference=image_ref, filename=filename + ) + job_queue.add_step(job_id, f"Download task started: {task_upid}") + except Exception as oci_error: + job_queue.set_failed( + job_id, + f"OCI download failed: {str(oci_error)}", + ) + return + + if not task_upid: + job_queue.set_failed(job_id, "Failed to start download task") + return + + # Wait for the download task to complete + job_queue.add_step(job_id, "Waiting for download to complete...") + job_queue.update_job(job_id, progress=20) + + result = wait_for_task(proxmox, node, task_upid, job_id, timeout=1800) + + if not result["success"]: + job_queue.set_failed( + job_id, f"Download failed: {result.get('error', 'Unknown error')}" + ) + return + + job_queue.add_step(job_id, "Download completed successfully!") + job_queue.update_job(job_id, progress=100) + + job_queue.set_completed( + job_id, + { + "template": template_name, + "storage": storage, + "source_type": source_type, + }, + ) + + except Exception as e: + job_queue.set_failed(job_id, str(e)) + + +@app.route("/api/node//download-lxc-template", methods=["POST"]) +def api_download_lxc_template(node): + """API endpoint to download LXC container template from various sources""" + if DEMO_MODE: + return ( + jsonify({"error": "Template download is disabled in demo mode"}), + 403, + ) + + proxmox = get_proxmox_connection(node, auto_renew=True) + if not proxmox: + return jsonify({"error": "Node not found"}), 404 + + data = request.json + if not data: + return jsonify({"error": "No data provided"}), 400 + + storage = data.get("storage") + if not storage: + return jsonify({"error": "Storage is required"}), 400 + + source_type = data.get("source_type", "proxmox") # proxmox, url, or oci + + # Validate inputs based on source type + template_name = "" + params = {"storage": storage, "source_type": source_type} + + try: + if source_type == "proxmox": + template = data.get("template") + if not template: + return jsonify({"error": "Template name is required"}), 400 + params["template"] = template + template_name = template + + elif source_type == "url": + url = data.get("url") + if not url: + return jsonify({"error": "URL is required"}), 400 + + filename = data.get("filename") + if not filename: + filename = url.split("/")[-1].split("?")[0] + if not filename or not ( + filename.endswith(".tar.xz") + or filename.endswith(".tar.gz") + or filename.endswith(".tar.zst") + ): + return ( + jsonify( + { + "error": "Could not determine filename from URL. Please provide a filename ending with .tar.xz, .tar.gz, or .tar.zst" + } + ), + 400, + ) + + params["url"] = url + params["filename"] = filename + template_name = filename + + elif source_type == "oci": + image = data.get("image") + if not image: + return jsonify({"error": "OCI image reference is required"}), 400 + + # Normalize the image reference + if not image.startswith("docker://"): + image = f"docker://{image}" + + # Generate filename from image reference + # e.g., docker://docker.io/library/alpine:3.14 -> alpine-3.14.tar + # OCI images use .tar extension (Proxmox adds compression internally) + image_ref = image.replace("docker://", "") + image_name = image_ref.split("/")[-1] # alpine:3.14 + if ":" in image_name: + name, tag = image_name.split(":", 1) + filename = f"{name}-{tag}.tar" + else: + filename = f"{image_name}-latest.tar" + + # Sanitize filename (replace invalid chars) + filename = filename.replace("/", "-").replace(":", "-") + + params["image"] = image + params["filename"] = filename + template_name = filename + + else: + return jsonify({"error": f"Unknown source type: {source_type}"}), 400 + + params["template_name"] = template_name + params["node"] = node + + # Create background job + job_id = job_queue.create_job( + job_type="lxc_template_download", + description=f"Download LXC template: {template_name}", + params=params, + ) + + # Start background thread + thread = threading.Thread( + target=run_lxc_template_download_job, + args=(job_id, node, params), + daemon=True, + ) + thread.start() + + return jsonify( + { + "success": True, + "message": f"Download started for {template_name}. Track progress in the Jobs dropdown.", + "job_id": job_id, + } + ) + + except Exception as e: + error_msg = str(e) + return jsonify({"error": f"Failed to start download: {error_msg}"}), 500 + + +@app.route("/api/node//lxc-storages") +def api_lxc_storages(node): + """API endpoint to get storages that support container templates (vztmpl)""" + proxmox = get_proxmox_connection(node, auto_renew=True) + if not proxmox: + return jsonify({"error": "Node not found"}), 404 + + try: + storages = proxmox.nodes(node).storage.get() + + # Filter for storages that can hold container templates + lxc_storages = [] + for storage in storages: + # Skip disabled storages + if storage.get("enabled") == 0 or storage.get("active") == 0: + continue + + content_types = storage.get("content", "").split(",") + # Check if storage supports vztmpl (container templates) + if "vztmpl" in content_types: + available_bytes = storage.get("avail", 0) + total_bytes = storage.get("total", 0) + lxc_storages.append( + { + "storage": storage.get("storage"), + "type": storage.get("type"), + "content": storage.get("content"), + "available": available_bytes, + "available_gb": ( + round(available_bytes / (1024**3), 2) + if available_bytes + else 0 + ), + "total": total_bytes, + "total_gb": ( + round(total_bytes / (1024**3), 2) if total_bytes else 0 + ), + } + ) + + return jsonify(lxc_storages) + + except Exception as e: + return jsonify({"error": str(e)}), 500 + + +@app.route("/api/node//lxc", methods=["POST"]) +def api_create_lxc(node): + """API endpoint to create a new LXC container from a template""" + if DEMO_MODE: + return ( + jsonify({"error": "Container creation is disabled in demo mode"}), + 403, + ) + + proxmox = get_proxmox_connection(node, auto_renew=True) + if not proxmox: + return jsonify({"error": "Node not found"}), 404 + + data = request.json + if not data: + return jsonify({"error": "No data provided"}), 400 + + # Required parameters + ostemplate = data.get("ostemplate") + hostname = data.get("hostname") + storage = data.get("storage") + + if not ostemplate: + return jsonify({"error": "Template (ostemplate) is required"}), 400 + if not hostname: + return jsonify({"error": "Hostname is required"}), 400 + if not storage: + return jsonify({"error": "Storage is required"}), 400 + + try: + # Get next available VMID if not provided + vmid = data.get("vmid") + if not vmid: + vmid = proxmox.cluster.nextid.get() + + # Build container creation parameters + params = { + "vmid": int(vmid), + "ostemplate": ostemplate, + "hostname": hostname, + "storage": storage, + "rootfs": f"{storage}:{data.get('rootfs_size', 8)}", + "cores": int(data.get("cores", 1)), + "memory": int(data.get("memory", 512)), + "swap": int(data.get("swap", 512)), + "unprivileged": 1 if data.get("unprivileged", True) else 0, + "start": 1 if data.get("start", False) else 0, + } + + # Network configuration + if data.get("net0"): + params["net0"] = data["net0"] + + # Password (optional) + if data.get("password"): + params["password"] = data["password"] + + # SSH public keys (optional) + if data.get("ssh_public_keys"): + params["ssh-public-keys"] = data["ssh_public_keys"] + + # Create the container + task = proxmox.nodes(node).lxc.post(**params) + + return jsonify( + { + "success": True, + "message": f"Container {vmid} creation started", + "vmid": vmid, + "upid": task, + } + ) + + except Exception as e: + error_msg = str(e) + return jsonify({"error": f"Failed to create container: {error_msg}"}), 500 + + @app.route("/api/node//networks") def api_node_networks(node): """API endpoint to get network interfaces for a specific node""" @@ -2950,15 +3344,26 @@ def api_vm_metrics(node, vmid): # VM Configuration API @app.route("/api/vm///config", methods=["GET", "PUT"]) def api_vm_config(node, vmid): - """Get or update VM configuration""" + """Get or update VM/Container configuration""" proxmox = get_proxmox_connection(node, auto_renew=True) if not proxmox: return jsonify({"error": "Node not found"}), 404 + # Determine if this is a QEMU VM or LXC container + vm_type = "qemu" + try: + proxmox.nodes(node).qemu(vmid).status.current.get() + except: + vm_type = "lxc" + try: if request.method == "GET": - # Get VM configuration - config = proxmox.nodes(node).qemu(vmid).config.get() + # Get VM/Container configuration + if vm_type == "qemu": + config = proxmox.nodes(node).qemu(vmid).config.get() + else: + config = proxmox.nodes(node).lxc(vmid).config.get() + config["_vm_type"] = vm_type # Include type in response return jsonify(config) elif request.method == "PUT": @@ -2982,12 +3387,20 @@ def api_vm_config(node, vmid): if "memory" in data: params["memory"] = int(data["memory"]) + # LXC-specific: Swap configuration + if "swap" in data and vm_type == "lxc": + params["swap"] = int(data["swap"]) + + # LXC-specific: Hostname + if "hostname" in data and vm_type == "lxc": + params["hostname"] = data["hostname"] + # Boot configuration if "onboot" in data: params["onboot"] = int(data["onboot"]) - # Display configuration - if "vga" in data: + # Display configuration (QEMU only) + if "vga" in data and vm_type == "qemu": params["vga"] = data["vga"] # Network interfaces (net0, net1, etc.) @@ -3011,11 +3424,14 @@ def api_vm_config(node, vmid): if delete_params: params["delete"] = ",".join(delete_params) - # Update VM configuration - proxmox.nodes(node).qemu(vmid).config.put(**params) + # Update VM/Container configuration + if vm_type == "qemu": + proxmox.nodes(node).qemu(vmid).config.put(**params) + else: + proxmox.nodes(node).lxc(vmid).config.put(**params) return jsonify( - {"success": True, "message": "VM configuration updated successfully"} + {"success": True, "message": "Configuration updated successfully"} ) except Exception as e: @@ -3024,11 +3440,18 @@ def api_vm_config(node, vmid): @app.route("/api/vm///resize-disk", methods=["PUT"]) def api_vm_resize_disk(node, vmid): - """Resize VM disk""" + """Resize VM/Container disk""" proxmox = get_proxmox_connection(node, auto_renew=True) if not proxmox: return jsonify({"error": "Node not found"}), 404 + # Determine if this is a QEMU VM or LXC container + vm_type = "qemu" + try: + proxmox.nodes(node).qemu(vmid).status.current.get() + except: + vm_type = "lxc" + try: data = request.get_json() if not data or "disk" not in data or "size" not in data: @@ -3044,7 +3467,10 @@ def api_vm_resize_disk(node, vmid): size = f"{size}G" # Use Proxmox API to resize disk - result = proxmox.nodes(node).qemu(vmid).resize.put(disk=disk, size=size) + if vm_type == "qemu": + result = proxmox.nodes(node).qemu(vmid).resize.put(disk=disk, size=size) + else: + result = proxmox.nodes(node).lxc(vmid).resize.put(disk=disk, size=size) return jsonify( { @@ -3333,6 +3759,7 @@ def api_vnc_ticket(node, vmid): """ Get a VNC ticket from Proxmox and create a proxy session. Returns a session ID that can be used to connect via WebSocket. + Supports both QEMU VMs and LXC containers. """ cleanup_expired_vnc_sessions() @@ -3340,6 +3767,13 @@ def api_vnc_ticket(node, vmid): if not proxmox: return jsonify({"error": "Node not found"}), 404 + # Determine if this is a QEMU VM or LXC container + vm_type = "qemu" + try: + proxmox.nodes(node).qemu(vmid).status.current.get() + except: + vm_type = "lxc" + # Get connection metadata for this node to get the host metadata = connection_metadata.get(node) if not metadata: @@ -3351,8 +3785,11 @@ def api_vnc_ticket(node, vmid): ) try: - # Request VNC ticket from Proxmox - vnc_data = proxmox.nodes(node).qemu(vmid).vncproxy.post(websocket=1) + # Request VNC ticket from Proxmox (both use vncproxy with websocket=1) + if vm_type == "qemu": + vnc_data = proxmox.nodes(node).qemu(vmid).vncproxy.post(websocket=1) + else: + vnc_data = proxmox.nodes(node).lxc(vmid).vncproxy.post(websocket=1) # Create a session for this VNC connection session_id = str(uuid.uuid4()) @@ -3396,6 +3833,7 @@ def api_vnc_ticket(node, vmid): "port": vnc_data["port"], "node": node, "vmid": vmid, + "vm_type": vm_type, "created_at": time.time(), "proxmox_host": metadata["host"], "verify_ssl": metadata.get("verify_ssl", True), @@ -3437,6 +3875,7 @@ def vnc_websocket_proxy(ws, session_id): ticket = session["ticket"] node = session["node"] vmid = session["vmid"] + vm_type = session.get("vm_type", "qemu") verify_ssl = session["verify_ssl"] auth_ticket = session.get("auth_ticket") auth_token = session.get("auth_token") @@ -3446,8 +3885,8 @@ def vnc_websocket_proxy(ws, session_id): encoded_ticket = quote(ticket, safe="") - # Proxmox VNC WebSocket URL format - proxmox_ws_url = f"wss://{proxmox_host}:8006/api2/json/nodes/{node}/qemu/{vmid}/vncwebsocket?port={port}&vncticket={encoded_ticket}" + # Proxmox WebSocket URL format (both use vncwebsocket) + proxmox_ws_url = f"wss://{proxmox_host}:8006/api2/json/nodes/{node}/{vm_type}/{vmid}/vncwebsocket?port={port}&vncticket={encoded_ticket}" # SSL context ssl_opts = {} @@ -3564,17 +4003,24 @@ def relay_to_proxmox(): @app.route("/vm///console") def vm_console(node, vmid): - """VNC console page for a VM""" + """VNC console page for a VM or Container""" proxmox = get_proxmox_connection(node, auto_renew=True) if not proxmox: flash("Proxmox connection not available") return redirect(url_for("index")) try: - # Get VM info for the title - vm_status = proxmox.nodes(node).qemu(vmid).status.current.get() - vm_config = proxmox.nodes(node).qemu(vmid).config.get() - vm_name = vm_config.get("name", f"VM {vmid}") + # Determine if this is a QEMU VM or LXC container + vm_type = "qemu" + try: + vm_status = proxmox.nodes(node).qemu(vmid).status.current.get() + vm_config = proxmox.nodes(node).qemu(vmid).config.get() + except: + vm_type = "lxc" + vm_status = proxmox.nodes(node).lxc(vmid).status.current.get() + vm_config = proxmox.nodes(node).lxc(vmid).config.get() + + vm_name = vm_config.get("name") or vm_config.get("hostname") or f"VM {vmid}" return render_template( "vm_console.html", @@ -3582,6 +4028,7 @@ def vm_console(node, vmid): vmid=vmid, vm_name=vm_name, vm_status=vm_status.get("status", "unknown"), + vm_type=vm_type, ) except Exception as e: flash(f"Error loading console: {str(e)}") diff --git a/templates/isos_templates.html b/templates/isos_templates.html index 2988bd2..1eb0269 100644 --- a/templates/isos_templates.html +++ b/templates/isos_templates.html @@ -176,6 +176,264 @@
Cloud-Init Configuration + + + + + +
@@ -339,8 +597,11 @@
VM Templates
-
+
Container Templates
+
@@ -501,9 +762,14 @@
Container Templates
${sizeGB} GB ${escapeHtml(filename)} - +
+ + +
`; @@ -1055,5 +1321,608 @@
Manual Download Required
hideLoading(); } } + +// LXC Template Import +let currentLxcNode = ''; +let availableLxcTemplates = null; + +async function showLXCTemplateModal(nodeName) { + currentLxcNode = nodeName; + + // Reset forms and state + document.getElementById('proxmoxTemplateForm').reset(); + document.getElementById('urlTemplateForm').reset(); + document.getElementById('ociTemplateForm').reset(); + document.getElementById('lxcImportProgress').classList.add('d-none'); + document.getElementById('lxcImportResult').classList.add('d-none'); + document.getElementById('importLxcBtn').disabled = false; + document.getElementById('lxcTemplateDesc').textContent = ''; + + // Reset to first tab + const firstTab = document.querySelector('#lxcSourceTabs button[data-bs-target="#proxmox-source"]'); + if (firstTab) { + bootstrap.Tab.getOrCreateInstance(firstTab).show(); + } + + // Load storages and templates in parallel + await Promise.all([ + loadLxcStorages(nodeName), + loadAvailableLxcTemplates(nodeName) + ]); + + // Show modal + const modal = new bootstrap.Modal(document.getElementById('lxcTemplateModal')); + modal.show(); +} + +async function loadLxcStorages(nodeName) { + const storageSelects = ['lxcStorage', 'lxcUrlStorage', 'lxcOciStorage']; + + // Set loading state for all selects + storageSelects.forEach(id => { + const select = document.getElementById(id); + if (select) { + select.innerHTML = ''; + } + }); + + try { + const response = await fetch(`/api/node/${nodeName}/lxc-storages`); + const storages = await response.json(); + + if (storages.error) { + storageSelects.forEach(id => { + const select = document.getElementById(id); + if (select) { + select.innerHTML = ''; + } + }); + showNotification('Error loading storages: ' + storages.error, 'error'); + return; + } + + if (!Array.isArray(storages) || storages.length === 0) { + storageSelects.forEach(id => { + const select = document.getElementById(id); + if (select) { + select.innerHTML = ''; + } + }); + showNotification('No storages that support container templates found', 'warning'); + return; + } + + // Populate all storage selects + storageSelects.forEach(id => { + const select = document.getElementById(id); + if (select) { + select.innerHTML = ''; + storages.forEach(storage => { + const option = document.createElement('option'); + option.value = storage.storage; + option.textContent = `${storage.storage} (${storage.type}) - ${storage.available_gb} GB free`; + select.appendChild(option); + }); + } + }); + + } catch (error) { + storageSelects.forEach(id => { + const select = document.getElementById(id); + if (select) { + select.innerHTML = ''; + } + }); + showNotification('Error loading storages: ' + error.message, 'error'); + } +} + +async function loadAvailableLxcTemplates(nodeName) { + const templateSelect = document.getElementById('lxcTemplate'); + const sectionSelect = document.getElementById('lxcSection'); + + templateSelect.innerHTML = ''; + sectionSelect.innerHTML = ''; + + try { + const response = await fetch(`/api/node/${nodeName}/available-lxc-templates`); + const data = await response.json(); + + if (data.error) { + templateSelect.innerHTML = ''; + sectionSelect.innerHTML = ''; + showNotification('Error loading templates: ' + data.error, 'error'); + return; + } + + availableLxcTemplates = data.templates; + + // Populate section/category dropdown + sectionSelect.innerHTML = ''; + const sections = Object.keys(availableLxcTemplates).sort(); + sections.forEach(section => { + const option = document.createElement('option'); + option.value = section; + // Capitalize first letter + option.textContent = section.charAt(0).toUpperCase() + section.slice(1) + + ` (${availableLxcTemplates[section].length})`; + sectionSelect.appendChild(option); + }); + + // Populate template dropdown with all templates + populateLxcTemplateSelect(''); + + } catch (error) { + templateSelect.innerHTML = ''; + sectionSelect.innerHTML = ''; + showNotification('Error loading templates: ' + error.message, 'error'); + } +} + +function filterTemplatesBySection() { + const section = document.getElementById('lxcSection').value; + populateLxcTemplateSelect(section); +} + +function populateLxcTemplateSelect(filterSection) { + const templateSelect = document.getElementById('lxcTemplate'); + templateSelect.innerHTML = ''; + + if (!availableLxcTemplates) return; + + const sections = filterSection ? [filterSection] : Object.keys(availableLxcTemplates).sort(); + + sections.forEach(section => { + const templates = availableLxcTemplates[section] || []; + if (templates.length === 0) return; + + const optgroup = document.createElement('optgroup'); + optgroup.label = section.charAt(0).toUpperCase() + section.slice(1); + + templates.forEach(tmpl => { + const option = document.createElement('option'); + option.value = tmpl.template; + option.textContent = tmpl.template; + option.dataset.headline = tmpl.headline || ''; + option.dataset.description = tmpl.description || ''; + option.dataset.os = tmpl.os || ''; + option.dataset.version = tmpl.version || ''; + optgroup.appendChild(option); + }); + + templateSelect.appendChild(optgroup); + }); + + // Add change event to show description + templateSelect.onchange = function() { + const selected = this.options[this.selectedIndex]; + const descDiv = document.getElementById('lxcTemplateDesc'); + if (selected && selected.dataset.headline) { + let desc = `${escapeHtml(selected.dataset.headline)}`; + if (selected.dataset.os) { + desc += ` ${escapeHtml(selected.dataset.os)}`; + } + if (selected.dataset.version) { + desc += ` ${escapeHtml(selected.dataset.version)}`; + } + descDiv.innerHTML = desc; + } else { + descDiv.innerHTML = ''; + } + }; +} + +async function importLXCTemplate() { + // Determine which tab is active + const activeTab = document.querySelector('#lxcSourceTabs .nav-link.active'); + const sourceType = activeTab.id.replace('-tab', ''); + + let storage, requestData; + + if (sourceType === 'proxmox') { + storage = document.getElementById('lxcStorage').value; + const template = document.getElementById('lxcTemplate').value; + + if (!storage) { + showNotification('Please select a storage', 'warning'); + return; + } + if (!template) { + showNotification('Please select a template', 'warning'); + return; + } + + requestData = { + storage: storage, + source_type: 'proxmox', + template: template + }; + + } else if (sourceType === 'url') { + storage = document.getElementById('lxcUrlStorage').value; + const url = document.getElementById('lxcUrl').value; + const filename = document.getElementById('lxcUrlFilename').value; + + if (!storage) { + showNotification('Please select a storage', 'warning'); + return; + } + if (!url) { + showNotification('Please enter a URL', 'warning'); + return; + } + + requestData = { + storage: storage, + source_type: 'url', + url: url, + filename: filename || undefined + }; + + } else if (sourceType === 'oci') { + storage = document.getElementById('lxcOciStorage').value; + const image = document.getElementById('lxcOciImage').value; + + if (!storage) { + showNotification('Please select a storage', 'warning'); + return; + } + if (!image) { + showNotification('Please enter an OCI image reference', 'warning'); + return; + } + + requestData = { + storage: storage, + source_type: 'oci', + image: image + }; + + } else { + showNotification('Unknown source type', 'error'); + return; + } + + // Show progress + const btn = document.getElementById('importLxcBtn'); + btn.disabled = true; + btn.innerHTML = ' Importing...'; + + document.getElementById('lxcImportProgress').classList.remove('d-none'); + document.getElementById('lxcImportResult').classList.add('d-none'); + document.getElementById('lxcImportProgressMsg').textContent = 'Starting download...'; + + try { + const response = await fetch(`/api/node/${currentLxcNode}/download-lxc-template`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(requestData) + }); + + const result = await response.json(); + + document.getElementById('lxcImportProgress').classList.add('d-none'); + document.getElementById('lxcImportResult').classList.remove('d-none'); + + if (result.success) { + document.getElementById('lxcImportResult').innerHTML = ` +
+ Download started!
+

${escapeHtml(result.message)}

+ ${result.job_id ? `

Job ID: ${escapeHtml(result.job_id)}

` : ''} +
+ + Track progress via the Jobs icon in the top navbar. + The template will appear in the list once completed. + +
+ `; + showNotification(result.message, 'success'); + + // Refresh global jobs dropdown to show the new job + if (typeof loadGlobalJobs === 'function') { + loadGlobalJobs(); + } + + // Close modal after a few seconds + setTimeout(() => { + const modal = bootstrap.Modal.getInstance(document.getElementById('lxcTemplateModal')); + if (modal) modal.hide(); + }, 3000); + + // Refresh templates list after longer delay to allow download to complete + setTimeout(() => { + loadTemplates(currentLxcNode); + }, 5000); + + } else { + // Check for manual command (OCI not supported) + if (result.manual_command) { + document.getElementById('lxcImportResult').innerHTML = ` +
+ Manual download required
+

${escapeHtml(result.error)}

+
+

Run this command on the Proxmox node:

+ ${escapeHtml(result.manual_command)} + +
+ `; + } else { + document.getElementById('lxcImportResult').innerHTML = ` +
+ Error: ${escapeHtml(result.error || 'Unknown error')} +
+ `; + showNotification(result.error || 'Import failed', 'error'); + } + } + + } catch (error) { + document.getElementById('lxcImportProgress').classList.add('d-none'); + document.getElementById('lxcImportResult').classList.remove('d-none'); + document.getElementById('lxcImportResult').innerHTML = ` +
+ Network error: ${escapeHtml(error.message)} +
+ `; + showNotification('Network error: ' + error.message, 'error'); + } finally { + btn.disabled = false; + btn.innerHTML = ' Import Template'; + } +} + +// Auto-fill filename when URL changes for LXC template +document.addEventListener('DOMContentLoaded', function() { + const urlInput = document.getElementById('lxcUrl'); + const filenameInput = document.getElementById('lxcUrlFilename'); + + if (urlInput && filenameInput) { + urlInput.addEventListener('input', function() { + if (this.value && !filenameInput.value) { + const url = this.value; + const filename = url.split('/').pop().split('?')[0]; + if (filename && (filename.endsWith('.tar.xz') || filename.endsWith('.tar.gz') || filename.endsWith('.tar.zst'))) { + filenameInput.value = filename; + } + } + }); + } +}); + +// Create Container from Template +let currentCreateNode = ''; +let currentCreateTemplate = ''; + +async function showCreateContainerModal(nodeName, templateVolid) { + currentCreateNode = nodeName; + currentCreateTemplate = templateVolid; + + // Reset form + document.getElementById('createContainerForm').reset(); + document.getElementById('createContainerProgress').classList.add('d-none'); + document.getElementById('createContainerResult').classList.add('d-none'); + document.getElementById('createContainerBtn').disabled = false; + document.getElementById('staticIpFields').classList.add('d-none'); + document.getElementById('containerUnprivileged').checked = true; + document.getElementById('containerStart').checked = true; + document.getElementById('containerCores').value = '1'; + document.getElementById('containerMemory').value = '512'; + document.getElementById('containerSwap').value = '512'; + document.getElementById('containerDiskSize').value = '8'; + + // Show template info + const filename = templateVolid.split('/').pop(); + document.getElementById('containerTemplateInfo').textContent = filename; + + // Auto-generate hostname from template name + const baseName = filename.replace('.tar.xz', '').replace('.tar.gz', '').replace('.tar.zst', '').replace('.tar', ''); + const safeName = baseName.replace(/[^a-zA-Z0-9\-]/g, '-').toLowerCase().substring(0, 20); + document.getElementById('containerHostname').value = safeName; + + // Load storages and bridges in parallel + await Promise.all([ + loadContainerStorages(nodeName), + loadContainerBridges(nodeName) + ]); + + // Show modal + const modal = new bootstrap.Modal(document.getElementById('createContainerModal')); + modal.show(); +} + +async function loadContainerStorages(nodeName) { + const select = document.getElementById('containerStorage'); + select.innerHTML = ''; + + try { + const response = await fetch(`/api/node/${nodeName}/storages?vm_type=lxc`); + const storages = await response.json(); + + if (storages.error) { + select.innerHTML = ''; + return; + } + + select.innerHTML = ''; + storages.forEach(storage => { + const option = document.createElement('option'); + option.value = storage.storage; + option.textContent = `${storage.storage} (${storage.type}) - ${storage.available_gb || 0} GB free`; + select.appendChild(option); + }); + } catch (error) { + select.innerHTML = ''; + } +} + +async function loadContainerBridges(nodeName) { + const select = document.getElementById('containerBridge'); + select.innerHTML = ''; + + try { + const response = await fetch(`/api/node/${nodeName}/networks`); + const networks = await response.json(); + + if (networks.error) { + select.innerHTML = ''; + return; + } + + select.innerHTML = ''; + // Add default option first + const defaultOption = document.createElement('option'); + defaultOption.value = 'vmbr0'; + defaultOption.textContent = 'vmbr0 (default)'; + select.appendChild(defaultOption); + + networks.forEach(net => { + if (net.iface && net.iface !== 'vmbr0') { + const option = document.createElement('option'); + option.value = net.iface; + option.textContent = `${net.iface}${net.comments ? ' - ' + net.comments : ''}`; + select.appendChild(option); + } + }); + } catch (error) { + select.innerHTML = ''; + } +} + +function toggleStaticIp() { + const ipConfig = document.getElementById('containerIpConfig').value; + const staticFields = document.getElementById('staticIpFields'); + if (ipConfig === 'static') { + staticFields.classList.remove('d-none'); + } else { + staticFields.classList.add('d-none'); + } +} + +async function createContainer() { + const hostname = document.getElementById('containerHostname').value.trim(); + const storage = document.getElementById('containerStorage').value; + + if (!hostname) { + showNotification('Please enter a hostname', 'warning'); + return; + } + if (!storage) { + showNotification('Please select a storage', 'warning'); + return; + } + + // Build request data + const data = { + ostemplate: currentCreateTemplate, + hostname: hostname, + storage: storage, + rootfs_size: parseInt(document.getElementById('containerDiskSize').value) || 8, + cores: parseInt(document.getElementById('containerCores').value) || 1, + memory: parseInt(document.getElementById('containerMemory').value) || 512, + swap: parseInt(document.getElementById('containerSwap').value) || 512, + unprivileged: document.getElementById('containerUnprivileged').checked, + start: document.getElementById('containerStart').checked + }; + + // Optional VMID + const vmid = document.getElementById('containerVmid').value; + if (vmid) { + data.vmid = parseInt(vmid); + } + + // Network configuration + const bridge = document.getElementById('containerBridge').value || 'vmbr0'; + const ipConfig = document.getElementById('containerIpConfig').value; + if (ipConfig === 'dhcp') { + data.net0 = `name=eth0,bridge=${bridge},ip=dhcp`; + } else { + const ip = document.getElementById('containerIp').value.trim(); + const gw = document.getElementById('containerGateway').value.trim(); + if (ip) { + data.net0 = `name=eth0,bridge=${bridge},ip=${ip}${gw ? ',gw=' + gw : ''}`; + } else { + data.net0 = `name=eth0,bridge=${bridge},ip=dhcp`; + } + } + + // Password + const password = document.getElementById('containerPassword').value; + if (password) { + data.password = password; + } + + // SSH keys + const sshKeys = document.getElementById('containerSshKeys').value.trim(); + if (sshKeys) { + data.ssh_public_keys = sshKeys; + } + + // Show progress + const btn = document.getElementById('createContainerBtn'); + btn.disabled = true; + btn.innerHTML = ' Creating...'; + + document.getElementById('createContainerProgress').classList.remove('d-none'); + document.getElementById('createContainerResult').classList.add('d-none'); + document.getElementById('createContainerProgressMsg').textContent = 'Creating container...'; + + try { + const response = await fetch(`/api/node/${currentCreateNode}/lxc`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }); + + const result = await response.json(); + + document.getElementById('createContainerProgress').classList.add('d-none'); + document.getElementById('createContainerResult').classList.remove('d-none'); + + if (result.success) { + document.getElementById('createContainerResult').innerHTML = ` +
+ Container created successfully!
+

Container ID: ${result.vmid}

+
+ + Go to Container + +
+ `; + showNotification(`Container ${result.vmid} created successfully!`, 'success'); + + // Close modal after a few seconds + setTimeout(() => { + const modal = bootstrap.Modal.getInstance(document.getElementById('createContainerModal')); + if (modal) modal.hide(); + }, 3000); + + } else { + document.getElementById('createContainerResult').innerHTML = ` +
+ Error: ${escapeHtml(result.error || 'Unknown error')} +
+ `; + showNotification(result.error || 'Failed to create container', 'error'); + } + + } catch (error) { + document.getElementById('createContainerProgress').classList.add('d-none'); + document.getElementById('createContainerResult').classList.remove('d-none'); + document.getElementById('createContainerResult').innerHTML = ` +
+ Network error: ${escapeHtml(error.message)} +
+ `; + showNotification('Network error: ' + error.message, 'error'); + } finally { + btn.disabled = false; + btn.innerHTML = ' Create Container'; + } +} {% endblock %} \ No newline at end of file diff --git a/templates/vm_console.html b/templates/vm_console.html index 953a24e..def1752 100644 --- a/templates/vm_console.html +++ b/templates/vm_console.html @@ -325,7 +325,7 @@
- +
@@ -424,13 +424,14 @@
- +