diff --git a/core/models.py b/core/models.py index 169ffd7..a080aee 100644 --- a/core/models.py +++ b/core/models.py @@ -39,6 +39,20 @@ class VMDefaultSettings(Base): cloud_init = Column(Text, nullable=True) +class ContainerDefaultSettings(Base): + __tablename__ = "container_default_settings" + + id = Column(Integer, primary_key=True, index=True) + username = Column(String, default="root") + cpu = Column(Integer, default=2) + memory = Column(Integer, default=2) + disk = Column(Integer, default=10) + image_fingerprint = Column(String, nullable=True) # LXD image fingerprint + image_alias = Column(String, nullable=True) # Image alias (e.g., "ubuntu/24.04") + image_description = Column(String, nullable=True) # Human-readable description + cloud_init = Column(Text, nullable=True) + + class ConnectionTemplate(Base): __tablename__ = "connection_templates" diff --git a/routes/dashboard.py b/routes/dashboard.py index a65954b..047a89e 100644 --- a/routes/dashboard.py +++ b/routes/dashboard.py @@ -1,7 +1,7 @@ """Dashboard routes""" from datetime import datetime from fastapi import APIRouter, Request, Depends -from fastapi.responses import HTMLResponse +from fastapi.responses import HTMLResponse, JSONResponse from fastapi.templating import Jinja2Templates from sqlalchemy.orm import Session @@ -114,3 +114,16 @@ async def dashboard( "disk_total": metrics["disk_total"], "disk_percent": metrics["disk_percent"] }) + + +@router.get("/api/vm-support") +async def check_vm_support(): + """ + Check if the system supports LXD virtual machines. + + Returns virtualization support status for UI to enable/disable VM options. + """ + from utils.virtualization import check_lxd_vm_support + + result = check_lxd_vm_support() + return JSONResponse(result) diff --git a/routes/instances.py b/routes/instances.py index 99852af..2bd728f 100644 --- a/routes/instances.py +++ b/routes/instances.py @@ -92,13 +92,31 @@ async def create_instance( "message": "LXD not configured. Please configure LXD in Settings first." }) - # Get VM default settings for cloud-init - vm_settings = db.query(VMDefaultSettings).first() - cloud_init_template = vm_settings.cloud_init if vm_settings and vm_settings.cloud_init else None - vm_swap = vm_settings.swap if vm_settings and vm_settings.swap else 2 - vm_username = vm_settings.username if vm_settings and vm_settings.username else "ubuntu" - image_fingerprint = vm_settings.image_fingerprint if vm_settings and vm_settings.image_fingerprint else None - + # Check if a system user with the instance name already exists (before creating instance) + # This prevents conflicts with existing system users + from services.jump_user_service import jump_user_exists + if jump_user_exists(name): + return JSONResponse({ + "success": False, + "message": f"A system user '{name}' already exists. Please choose a different instance name." + }) + + # Get VM or container default settings based on instance type + if instance_type == "container": + from core.models import ContainerDefaultSettings + instance_settings = db.query(ContainerDefaultSettings).first() + cloud_init_template = instance_settings.cloud_init if instance_settings and instance_settings.cloud_init else None + vm_swap = 2 # Not used for containers + vm_username = instance_settings.username if instance_settings else "root" + image_fingerprint = instance_settings.image_fingerprint if instance_settings else None + else: + from core.models import VMDefaultSettings + instance_settings = db.query(VMDefaultSettings).first() + cloud_init_template = instance_settings.cloud_init if instance_settings and instance_settings.cloud_init else None + vm_swap = instance_settings.swap if instance_settings and instance_settings.swap else 2 + vm_username = instance_settings.username if instance_settings and instance_settings.username else "ubuntu" + image_fingerprint = instance_settings.image_fingerprint if instance_settings and instance_settings.image_fingerprint else None + # Pass the raw template (with placeholders) to the background task # The background task will generate SSH keys and process the template cloud_init = cloud_init_template @@ -301,12 +319,31 @@ async def bulk_create_instances( "message": "LXD not configured. Please configure LXD in Settings first." }) - # Get VM default settings - vm_settings = db.query(VMDefaultSettings).first() - cloud_init = vm_settings.cloud_init if vm_settings else None - vm_swap = vm_settings.swap if vm_settings else 2 - vm_username = vm_settings.username if vm_settings else "ubuntu" - image_fingerprint = vm_settings.image_fingerprint if vm_settings else None + # Check if any system user with the instance names already exists (before creating instances) + # This prevents conflicts with existing system users + from services.jump_user_service import jump_user_exists + for name in instance_names: + if jump_user_exists(name): + return JSONResponse({ + "success": False, + "message": f"A system user '{name}' already exists. Please choose different instance names." + }) + + # Get VM or container default settings based on instance type + if instance_type == "container": + from core.models import ContainerDefaultSettings + instance_settings = db.query(ContainerDefaultSettings).first() + cloud_init = instance_settings.cloud_init if instance_settings and instance_settings.cloud_init else None + vm_swap = 2 # Not used for containers + vm_username = instance_settings.username if instance_settings else "root" + image_fingerprint = instance_settings.image_fingerprint if instance_settings else None + else: + from core.models import VMDefaultSettings + instance_settings = db.query(VMDefaultSettings).first() + cloud_init = instance_settings.cloud_init if instance_settings and instance_settings.cloud_init else None + vm_swap = instance_settings.swap if instance_settings and instance_settings.swap else 2 + vm_username = instance_settings.username if instance_settings else "ubuntu" + image_fingerprint = instance_settings.image_fingerprint if instance_settings else None lxd_settings = { "use_socket": lxd_settings_db.use_socket, @@ -584,17 +621,13 @@ async def delete_instance( ): """Delete an LXD instance and its SSH keys""" from services.jump_user_service import delete_jump_user - + lxd_service = LXDService(db) lxd_service.get_client() if not lxd_service.is_connected(): return JSONResponse({"success": False, "message": "LXD not configured"}) - # Get VM settings for username (to delete jump user) - vm_settings = db.query(VMDefaultSettings).first() - username = vm_settings.username if vm_settings else "ubuntu" - # Delete the LXD instance result = lxd_service.delete_instance(instance_name) @@ -612,9 +645,9 @@ async def delete_instance( except ValueError: result["message"] = "Instance deleted, invalid instance name format" - # Delete jump user from host system + # Delete jump user from host system (using instance name as the jump user) try: - delete_jump_user(username) + delete_jump_user(instance_name) except Exception: pass # Don't fail deletion if jump user cleanup fails @@ -670,7 +703,21 @@ async def download_ssh_config( "message": "SSH keys not found for this instance" }) - # Get VM settings for username + # Create/update jump user on host system using instance name as username + # This ensures each instance has its own unique jump user + jump_user_result = create_jump_user( + username=instance_name, # Fallback (not used when instance_name provided) + public_key=keys["public_key"], + instance_name=instance_name # Use instance name as the jump user + ) + if not jump_user_result.get("success"): + return JSONResponse({ + "success": False, + "message": f"Failed to setup jump user: {jump_user_result.get('message')}" + }) + + # Get instance settings for username (used for VM login, not jump user) + instance_type = "virtual-machine" # Default, could be fetched from LXD if needed vm_settings = db.query(VMDefaultSettings).first() username = vm_settings.username if vm_settings else "ubuntu" @@ -678,14 +725,6 @@ async def download_ssh_config( templates = db.query(ConnectionTemplate).first() ssh_template = templates.ssh_config_template if templates and templates.ssh_config_template else DEFAULT_SSH_CONFIG_TEMPLATE - # Create/update jump user on host system - jump_user_result = create_jump_user(username, keys["public_key"]) - if not jump_user_result.get("success"): - return JSONResponse({ - "success": False, - "message": f"Failed to setup jump user: {jump_user_result.get('message')}" - }) - # Get VM IP address from services.lxd_service import LXDService lxd_service = LXDService(db) diff --git a/routes/settings.py b/routes/settings.py index 4ab4cb3..5c21006 100644 --- a/routes/settings.py +++ b/routes/settings.py @@ -5,7 +5,7 @@ from sqlalchemy.orm import Session from core.database import get_db -from core.models import AdminUser, LXDSettings, VMDefaultSettings +from core.models import AdminUser, LXDSettings, VMDefaultSettings, ContainerDefaultSettings from core.config import settings from core.security import get_password_hash, verify_password from services.lxd_service import LXDService @@ -58,13 +58,16 @@ async def settings_page( password_error: str = None, vm_success: str = None, vm_error: str = None, + container_success: str = None, + container_error: str = None, templates_success: str = None ): - """Settings page - change password, LXD configuration, and VM defaults""" + """Settings page - change password, LXD configuration, and VM/container defaults""" from core.models import ConnectionTemplate - + lxd_settings = db.query(LXDSettings).first() vm_settings = db.query(VMDefaultSettings).first() + container_settings = db.query(ContainerDefaultSettings).first() connection_templates = db.query(ConnectionTemplate).first() return templates.TemplateResponse("admin/settings.html", { @@ -72,6 +75,7 @@ async def settings_page( "username": user.username, "lxd_settings": lxd_settings, "vm_settings": vm_settings, + "container_settings": container_settings, "connection_templates": connection_templates, "lxd_success": lxd_success, "lxd_error": lxd_error, @@ -79,6 +83,8 @@ async def settings_page( "password_error": password_error, "vm_success": vm_success, "vm_error": vm_error, + "container_success": container_success, + "container_error": container_error, "templates_success": templates_success }) @@ -212,14 +218,14 @@ async def save_vm_settings( """Save default VM settings""" # Validate username from core.validators import validate_username, validate_positive_integer - + is_valid, error = validate_username(username) if not is_valid: return RedirectResponse( url=f"/settings?vm_error={error}", status_code=303 ) - + # Validate cloud-init template if provided if cloud_init.strip(): from services.cloud_init_service import validate_cloud_init_template @@ -261,6 +267,69 @@ async def save_vm_settings( return RedirectResponse(url="/settings?vm_success=VM defaults saved successfully", status_code=303) +@router.post("/settings/container") +async def save_container_settings( + request: Request, + username: str = Form(...), + cpu: int = Form(...), + memory: int = Form(...), + disk: int = Form(...), + image_fingerprint: str = Form(""), + image_alias: str = Form(""), + image_description: str = Form(""), + cloud_init: str = Form(""), + db: Session = Depends(get_db) +): + """Save default container settings""" + # Validate username + from core.validators import validate_username + + is_valid, error = validate_username(username) + if not is_valid: + return RedirectResponse( + url=f"/settings?container_error={error}", + status_code=303 + ) + + # Validate cloud-init template if provided + if cloud_init.strip(): + from services.cloud_init_service import validate_cloud_init_template + is_valid, error_msg = validate_cloud_init_template(cloud_init) + if not is_valid: + return RedirectResponse( + url=f"/settings?container_error={error_msg}", + status_code=303 + ) + + settings = db.query(ContainerDefaultSettings).first() + + if settings: + settings.username = username + settings.cpu = cpu + settings.memory = memory + settings.disk = disk + settings.image_fingerprint = image_fingerprint if image_fingerprint else None + settings.image_alias = image_alias if image_alias else None + settings.image_description = image_description if image_description else None + settings.cloud_init = cloud_init if cloud_init else None + else: + settings = ContainerDefaultSettings( + username=username, + cpu=cpu, + memory=memory, + disk=disk, + image_fingerprint=image_fingerprint if image_fingerprint else None, + image_alias=image_alias if image_alias else None, + image_description=image_description if image_description else None, + cloud_init=cloud_init if cloud_init else None + ) + db.add(settings) + + db.commit() + + return RedirectResponse(url="/settings?container_success=Container defaults saved successfully", status_code=303) + + @router.get("/settings/vm/template") async def get_cloud_init_template(): """Get the default cloud-init template""" @@ -272,25 +341,32 @@ async def get_cloud_init_template(): @router.get("/settings/vm/images") -async def get_available_images(db: Session = Depends(get_db)): - """Get available LXD images for VM creation""" +async def get_available_images( + db: Session = Depends(get_db), + instance_type: str = "virtual-machine" +): + """Get available LXD images for VM or container creation""" from services.lxd_service import LXDService - + lxd_service = LXDService(db) lxd_service.get_client() - + if not lxd_service.is_connected(): return JSONResponse({ "success": False, "message": "LXD not connected" }) - + try: images = [] for img in lxd_service.client.images.all(): + # Filter by instance type + if img.type != instance_type: + continue + # Get image info description = img.properties.get('description', 'Unknown') - + # Handle aliases which can be dicts or objects aliases = [] for a in img.aliases: @@ -300,7 +376,7 @@ async def get_available_images(db: Session = Depends(get_db)): name = getattr(a, 'name', None) if name: aliases.append(name) - + # Handle created_at which might be datetime or string created_at = None if img.created_at: @@ -308,21 +384,21 @@ async def get_available_images(db: Session = Depends(get_db)): created_at = img.created_at.isoformat() else: created_at = str(img.created_at) - + images.append({ "fingerprint": img.fingerprint[:12], # Short fingerprint "full_fingerprint": img.fingerprint, - "description": f"{img.type}: {description}", + "description": f"{description}", "aliases": aliases, "architecture": img.architecture, "type": img.type, "size": img.size, "created_at": created_at }) - + # Sort by description images.sort(key=lambda x: x['description']) - + return JSONResponse({ "success": True, "images": images diff --git a/services/cloud_init_service.py b/services/cloud_init_service.py index 120c979..25d18c2 100644 --- a/services/cloud_init_service.py +++ b/services/cloud_init_service.py @@ -68,7 +68,7 @@ def validate_cloud_init_template(template: str) -> tuple[bool, str]: Returns: Tuple of (is_valid, error_message) """ - required_placeholders = ['{username}', '{public_key}', '{swap_size}'] + required_placeholders = ['{username}', '{public_key}'] missing = [] for placeholder in required_placeholders: diff --git a/services/instance_tasks.py b/services/instance_tasks.py index 7d20480..9037c6c 100644 --- a/services/instance_tasks.py +++ b/services/instance_tasks.py @@ -87,7 +87,7 @@ def create_instance_background( creation_tasks[task_id]["progress"] = 25 creation_tasks[task_id]["message"] = "Preparing instance configuration..." - # Generate SSH key pair for this VM (only for VMs, not containers) + # Generate SSH key pair for this instance ssh_keys = None try: creation_tasks[task_id]["message"] = "Generating SSH key pair..." diff --git a/services/jump_user_service.py b/services/jump_user_service.py index f9960e8..901c526 100644 --- a/services/jump_user_service.py +++ b/services/jump_user_service.py @@ -73,30 +73,37 @@ def jump_user_exists(username: str) -> bool: return False -def create_jump_user(username: str, public_key: str) -> Dict: +def create_jump_user(username: str, public_key: str, instance_name: str = None) -> Dict: """ Create a jump user on the host system for SSH ProxyJump. Args: - username: Username for the jump user (validated) + username: Username for the jump user (validated) - typically the instance name public_key: SSH public key in OpenSSH format + instance_name: Optional instance name to use as username (overrides username param) Returns: dict with success status and message """ + # Use instance_name as username if provided (ensures uniqueness) + safe_username = instance_name if instance_name else username + try: # Validate inputs - safe_username = _sanitize_username(username) - + safe_username = _sanitize_username(safe_username) + if not _validate_ssh_public_key(public_key): return { "success": False, "message": "Invalid SSH public key format" } - - # Check if user already exists - if jump_user_exists(username): - return update_jump_user_keys(username, public_key) + + # Check if user already exists - if so, this is a conflict + if jump_user_exists(safe_username): + return { + "success": False, + "message": f"User '{safe_username}' already exists on the host system" + } # Create user with nologin shell (no shell interpolation) subprocess.run( diff --git a/services/ssh_config_service.py b/services/ssh_config_service.py index 8124c2f..981bf00 100644 --- a/services/ssh_config_service.py +++ b/services/ssh_config_service.py @@ -9,16 +9,16 @@ # SSH configuration for {vm_name} # Generated by Sandbox Manager -Host jump-host +Host jump-host-{vm_name} HostName {host_ip} - User {username} + User {vm_name} IdentityFile {private_key_filename} StrictHostKeyChecking no Host {vm_name} HostName {vm_hostname} User {username} - ProxyJump jump-host + ProxyJump jump-host-{vm_name} IdentityFile {private_key_filename} StrictHostKeyChecking no # --- End of config block --- diff --git a/static/js/settings.js b/static/js/settings.js index 69c5182..ba970d7 100644 --- a/static/js/settings.js +++ b/static/js/settings.js @@ -120,22 +120,72 @@ async function loadCloudInitTemplate() { } // Load available LXD images -async function loadImages() { +async function loadImages(instanceType = 'virtual-machine') { const select = document.getElementById('image_select'); const descField = document.getElementById('image_description'); const fpField = document.getElementById('image_fingerprint'); const aliasField = document.getElementById('image_alias'); - + select.disabled = true; select.innerHTML = ''; - + try { - const response = await fetch('/settings/vm/images'); + const response = await fetch(`/settings/vm/images?instance_type=${instanceType}`); const data = await response.json(); - + + if (data.success && data.images.length > 0) { + select.innerHTML = ''; + + data.images.forEach(img => { + const aliasText = img.aliases.length > 0 ? ` (${img.aliases.join(', ')})` : ''; + const option = document.createElement('option'); + option.value = img.fingerprint; + option.textContent = `${img.description}${aliasText}`; + option.dataset.fullFingerprint = img.full_fingerprint; + option.dataset.alias = img.aliases.length > 0 ? img.aliases[0] : ''; + option.dataset.description = img.description; + select.appendChild(option); + }); + + // Restore previously selected image + if (fpField.value) { + for (let opt of select.options) { + if (opt.dataset.fullFingerprint === fpField.value || opt.value === fpField.value) { + opt.selected = true; + descField.value = opt.dataset.description; + aliasField.value = opt.dataset.alias; + break; + } + } + } + } else { + select.innerHTML = ''; + } + } catch (error) { + select.innerHTML = ``; + console.error('Failed to load images:', error); + } finally { + select.disabled = false; + } +} + +// Load available LXD container images +async function loadContainerImages() { + const select = document.getElementById('container_image_select'); + const descField = document.getElementById('container_image_description'); + const fpField = document.getElementById('container_image_fingerprint'); + const aliasField = document.getElementById('container_image_alias'); + + select.disabled = true; + select.innerHTML = ''; + + try { + const response = await fetch('/settings/vm/images?instance_type=container'); + const data = await response.json(); + if (data.success && data.images.length > 0) { select.innerHTML = ''; - + data.images.forEach(img => { const aliasText = img.aliases.length > 0 ? ` (${img.aliases.join(', ')})` : ''; const option = document.createElement('option'); @@ -146,7 +196,7 @@ async function loadImages() { option.dataset.description = img.description; select.appendChild(option); }); - + // Restore previously selected image if (fpField.value) { for (let opt of select.options) { @@ -169,6 +219,28 @@ async function loadImages() { } } +// Load cloud-init template for containers +async function loadContainerCloudInitTemplate() { + const cloudInitField = document.getElementById('container_cloud_init'); + cloudInitField.disabled = true; + cloudInitField.placeholder = 'Loading template...'; + + try { + const response = await fetch('/settings/vm/template'); + const data = await response.json(); + + if (data.success) { + cloudInitField.value = data.template; + } else { + cloudInitField.value = '# Failed to load template'; + } + } catch (error) { + cloudInitField.value = `# Error loading template: ${error.message}`; + } finally { + cloudInitField.disabled = false; + } +} + // Load connection templates async function loadConnectionTemplates() { const sshConfigField = document.getElementById('ssh_config_template'); @@ -191,9 +263,29 @@ function onImageSelect() { const descField = document.getElementById('image_description'); const fpField = document.getElementById('image_fingerprint'); const aliasField = document.getElementById('image_alias'); - + const selectedOption = select.options[select.selectedIndex]; - + + if (selectedOption.value) { + descField.value = selectedOption.dataset.description || selectedOption.textContent; + fpField.value = selectedOption.dataset.fullFingerprint || selectedOption.value; + aliasField.value = selectedOption.dataset.alias || ''; + } else { + descField.value = ''; + fpField.value = ''; + aliasField.value = ''; + } +} + +// Handle container image selection +function onContainerImageSelect() { + const select = document.getElementById('container_image_select'); + const descField = document.getElementById('container_image_description'); + const fpField = document.getElementById('container_image_fingerprint'); + const aliasField = document.getElementById('container_image_alias'); + + const selectedOption = select.options[select.selectedIndex]; + if (selectedOption.value) { descField.value = selectedOption.dataset.description || selectedOption.textContent; fpField.value = selectedOption.dataset.fullFingerprint || selectedOption.value; @@ -219,6 +311,8 @@ document.addEventListener('DOMContentLoaded', function() { const passwordError = document.getElementById('password-error')?.value || ''; const vmSuccess = document.getElementById('vm-success')?.value || ''; const vmError = document.getElementById('vm-error')?.value || ''; + const containerSuccess = document.getElementById('container-success')?.value || ''; + const containerError = document.getElementById('container-error')?.value || ''; const templatesSuccess = document.getElementById('templates-success')?.value || ''; const templatesError = document.getElementById('templates-error')?.value || ''; @@ -227,6 +321,11 @@ document.addEventListener('DOMContentLoaded', function() { templatesTab.show(); // Load templates when tab is shown loadConnectionTemplates(); + } else if (containerSuccess || containerError) { + const containerTab = new bootstrap.Tab(document.getElementById('container-settings-tab')); + containerTab.show(); + // Load container images when container tab is shown + loadContainerImages(); } else if (vmSuccess || vmError) { const vmTab = new bootstrap.Tab(document.getElementById('vm-settings-tab')); vmTab.show(); @@ -236,7 +335,7 @@ document.addEventListener('DOMContentLoaded', function() { const passwordTab = new bootstrap.Tab(document.getElementById('password-tab')); passwordTab.show(); } - + // Load connection templates when tab is clicked const connectionTemplatesTab = document.getElementById('connection-templates-tab'); if (connectionTemplatesTab) { @@ -244,4 +343,20 @@ document.addEventListener('DOMContentLoaded', function() { loadConnectionTemplates(); }); } + + // Load VM images when VM tab is shown + const vmSettingsTab = document.getElementById('vm-settings-tab'); + if (vmSettingsTab) { + vmSettingsTab.addEventListener('shown.bs.tab', function() { + loadImages(); + }); + } + + // Load container images when Container tab is shown + const containerSettingsTab = document.getElementById('container-settings-tab'); + if (containerSettingsTab) { + containerSettingsTab.addEventListener('shown.bs.tab', function() { + loadContainerImages(); + }); + } }); diff --git a/static/js/vm-support.js b/static/js/vm-support.js new file mode 100644 index 0000000..15abada --- /dev/null +++ b/static/js/vm-support.js @@ -0,0 +1,106 @@ +/** + * Virtualization support detection + * Checks if the system supports hardware virtualization for VMs + */ + +let vmSupportInfo = null; + +/** + * Check VM support from API and cache the result + */ +export async function checkVMSupport() { + if (vmSupportInfo !== null) { + return vmSupportInfo; + } + + try { + const response = await fetch('/api/vm-support'); + const data = await response.json(); + vmSupportInfo = data; + return data; + } catch (error) { + console.error('Failed to check VM support:', error); + // Default to assuming VMs are supported if check fails + return { + vm_supported: true, + container_supported: true, + recommendation: '' + }; + } +} + +/** + * Disable VM options in the UI if virtualization is not supported + */ +export function disableVMOptionsIfUnsupported() { + checkVMSupport().then(info => { + if (!info.vm_supported) { + // Disable VM option in instance type dropdown + const instanceTypeSelect = document.getElementById('instance_type'); + if (instanceTypeSelect) { + const vmOption = instanceTypeSelect.querySelector('option[value="virtual-machine"]'); + if (vmOption) { + vmOption.disabled = true; + vmOption.textContent = 'VM (Not available on this system)'; + + // If VM is currently selected, switch to container + if (instanceTypeSelect.value === 'virtual-machine') { + instanceTypeSelect.value = 'container'; + } + } + } + + // Show warning message in create form + const createForm = document.getElementById('create-form'); + if (createForm) { + const existingWarning = createForm.querySelector('.vm-not-supported-warning'); + if (!existingWarning) { + const warningDiv = document.createElement('div'); + warningDiv.className = 'alert alert-warning vm-not-supported-warning'; + warningDiv.innerHTML = ` + + Virtual machines not available: ${info.recommendation} + `; + createForm.insertBefore(warningDiv, createForm.firstChild); + } + } + + // Disable VM Settings tab + const vmSettingsTab = document.getElementById('vm-settings-tab'); + const vmSettingsPane = document.getElementById('vm-settings'); + if (vmSettingsTab) { + vmSettingsTab.classList.add('disabled'); + vmSettingsTab.style.pointerEvents = 'none'; + vmSettingsTab.style.opacity = '0.5'; + vmSettingsTab.title = 'Virtual machines are not supported on this system'; + + // Add tooltip explanation + const tooltip = document.createElement('span'); + tooltip.className = 'badge bg-warning ms-2'; + tooltip.textContent = 'Not Available'; + vmSettingsTab.appendChild(tooltip); + } + if (vmSettingsPane) { + vmSettingsPane.classList.add('disabled'); + } + + // Auto-switch to Container Settings tab if on VM Settings + const containerSettingsTab = document.getElementById('container-settings-tab'); + if (containerSettingsTab && vmSettingsTab) { + const activeTab = document.querySelector('.nav-link.active'); + if (activeTab === vmSettingsTab) { + containerSettingsTab.click(); + } + } + + console.log('VM support disabled:', info); + } + }); +} + +/** + * Get cached VM support info + */ +export function getVMSupportInfo() { + return vmSupportInfo; +} diff --git a/templates/admin/dashboard.html b/templates/admin/dashboard.html index 2c30e3a..607271a 100644 --- a/templates/admin/dashboard.html +++ b/templates/admin/dashboard.html @@ -168,8 +168,8 @@
@@ -502,8 +502,8 @@
@@ -551,5 +551,12 @@