From de2d4e30834e40a7ce8679e5f503b3120bf35570 Mon Sep 17 00:00:00 2001 From: Saiful Amin Date: Wed, 25 Mar 2026 14:18:24 +0000 Subject: [PATCH 01/34] docs: add SSH LocalForward help text to settings Add helpful documentation link explaining how to use LocalForward to access services running on the VM from the host machine. --- templates/admin/settings.html | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/templates/admin/settings.html b/templates/admin/settings.html index 4ce6f82..143e85e 100644 --- a/templates/admin/settings.html +++ b/templates/admin/settings.html @@ -408,6 +408,10 @@
SSH Config Template
Available placeholders: {vm_name}, {host_ip}, {username}, {private_key_filename}, {vm_hostname} +

+ Use LocalForward + to access services running on the VM from your host machine. +

+ `; +} diff --git a/templates/admin/dashboard.html b/templates/admin/dashboard.html index 607271a..d0bde93 100644 --- a/templates/admin/dashboard.html +++ b/templates/admin/dashboard.html @@ -148,31 +148,37 @@
- + + +
+
+ + +
+
+
-
+
-
+
- - -
-
@@ -483,6 +489,12 @@
+
+ + +
@@ -552,7 +564,7 @@
- -
-
- -
-
-
- Classrooms - -
-
-
- Loading classrooms… -
- -
-
-
- - -
- - - - - - - - - - -
-
- -

Select a classroom to edit or create a new one

-
-
-
-
-
- - -
-
- -
-
-
- Profiles - -
-
-
- Loading profiles… -
- -
-
-
- - -
- - - - - - - - - - -
-
- -

Select a profile to edit or create a new one

-
-
-
-
-
-
@@ -488,5 +175,5 @@
HTTPS (Remote LXD):
{% endblock %} {% block extra_js %} - + {% endblock %} diff --git a/templates/base_admin.html b/templates/base_admin.html index f15999d..4cea55a 100644 --- a/templates/base_admin.html +++ b/templates/base_admin.html @@ -22,6 +22,9 @@ Dashboard + + Classrooms + Settings From 3625384a252bb09beccd200af27de76494fa0313 Mon Sep 17 00:00:00 2001 From: Saiful Amin Date: Thu, 26 Mar 2026 08:55:11 +0000 Subject: [PATCH 08/34] refactor: use classrooms for instance creation configuration Replaces LXD profile selector with classroom selector in the instance creation forms. When a classroom is selected, its settings (image_type, username, image_fingerprint, lxd_profile) are used for instance creation. Added lxd_profile parameter throughout the instance creation pipeline to enable profile application after instance creation. --- routes/instances.py | 50 +++++++++++--- services/bulk_service.py | 19 ++--- services/instance_tasks.py | 31 ++++++++- static/js/dashboard.js | 123 ++++++++++++++++++++------------- templates/admin/dashboard.html | 38 +++------- 5 files changed, 165 insertions(+), 96 deletions(-) diff --git a/routes/instances.py b/routes/instances.py index bcc2fe9..76abc38 100644 --- a/routes/instances.py +++ b/routes/instances.py @@ -101,10 +101,24 @@ async def create_instance( "message": f"A system user '{name}' already exists. Please choose a different instance name." }) - # Get classroom settings (use first classroom as default, or None) - classroom = db.query(Classroom).first() - vm_username = classroom.username if classroom else ("root" if instance_type == "container" else "ubuntu") - image_fingerprint = classroom.image_fingerprint if classroom else None + # Get classroom from request if provided, otherwise use first classroom + classroom_id = data.get("classroom_id") + lxd_profile = data.get("lxd_profile") + if classroom_id: + classroom = db.query(Classroom).filter(Classroom.id == classroom_id).first() + else: + classroom = db.query(Classroom).first() + + # Use classroom settings if available + if classroom: + instance_type = classroom.image_type or instance_type + vm_username = classroom.username + image_fingerprint = classroom.image_fingerprint + lxd_profile = lxd_profile or classroom.lxd_profile + else: + vm_username = "root" if instance_type == "container" else "ubuntu" + image_fingerprint = None + cloud_init = None # Cloud-init is now handled via LXD profiles lxd_settings = { @@ -126,7 +140,8 @@ async def create_instance( cloud_init=cloud_init, vm_swap=2, # Default swap, can be set in cloud-init vm_username=vm_username, - image_fingerprint=image_fingerprint + image_fingerprint=image_fingerprint, + lxd_profile=lxd_profile ) return JSONResponse({ @@ -315,10 +330,24 @@ async def bulk_create_instances( "message": f"A system user '{name}' already exists. Please choose different instance names." }) - # Get classroom settings (use first classroom as default, or None) - classroom = db.query(Classroom).first() - vm_username = classroom.username if classroom else "ubuntu" - image_fingerprint = classroom.image_fingerprint if classroom else None + # Get classroom from request if provided, otherwise use first classroom + classroom_id = data.get("classroom_id") + lxd_profile = data.get("lxd_profile") + if classroom_id: + classroom = db.query(Classroom).filter(Classroom.id == classroom_id).first() + else: + classroom = db.query(Classroom).first() + + # Use classroom settings if available + if classroom: + instance_type = classroom.image_type or instance_type + vm_username = classroom.username + image_fingerprint = classroom.image_fingerprint + lxd_profile = lxd_profile or classroom.lxd_profile + else: + vm_username = "ubuntu" + image_fingerprint = None + cloud_init = None # Cloud-init is now handled via LXD profiles lxd_settings = { @@ -340,7 +369,8 @@ async def bulk_create_instances( cloud_init=cloud_init, vm_swap=2, # Default swap, can be set in cloud-init vm_username=vm_username, - image_fingerprint=image_fingerprint + image_fingerprint=image_fingerprint, + lxd_profile=lxd_profile ) return JSONResponse({ diff --git a/services/bulk_service.py b/services/bulk_service.py index 726efba..f45e3a6 100644 --- a/services/bulk_service.py +++ b/services/bulk_service.py @@ -210,7 +210,8 @@ def bulk_create_instances( cloud_init: Optional[str] = None, vm_swap: int = 2, vm_username: str = "ubuntu", - image_fingerprint: Optional[str] = None + image_fingerprint: Optional[str] = None, + lxd_profile: Optional[str] = None ): """ Background task to create multiple instances. @@ -264,7 +265,8 @@ def bulk_create_instances( cloud_init=cloud_init, vm_swap=vm_swap, vm_username=vm_username, - image_fingerprint=image_fingerprint + image_fingerprint=image_fingerprint, + lxd_profile=lxd_profile ) # Wait for this instance to complete before starting next @@ -336,24 +338,25 @@ def start_bulk_create( cloud_init: Optional[str] = None, vm_swap: int = 2, vm_username: str = "ubuntu", - image_fingerprint: Optional[str] = None + image_fingerprint: Optional[str] = None, + lxd_profile: Optional[str] = None ) -> str: """ Start a bulk creation operation and return operation ID. - + Returns: Operation ID for tracking progress """ op_id = str(uuid.uuid4()) - + thread = threading.Thread( target=BulkOperationService.bulk_create_instances, - args=(op_id, instance_names, cpu, ram, disk, instance_type, - lxd_settings, cloud_init, vm_swap, vm_username, image_fingerprint) + args=(op_id, instance_names, cpu, ram, disk, instance_type, + lxd_settings, cloud_init, vm_swap, vm_username, image_fingerprint, lxd_profile) ) thread.daemon = True thread.start() - + return op_id @staticmethod diff --git a/services/instance_tasks.py b/services/instance_tasks.py index 9037c6c..b1e8e92 100644 --- a/services/instance_tasks.py +++ b/services/instance_tasks.py @@ -45,7 +45,8 @@ def create_instance_background( cloud_init: Optional[str] = None, vm_swap: int = 2, vm_username: str = "ubuntu", - image_fingerprint: Optional[str] = None + image_fingerprint: Optional[str] = None, + lxd_profile: Optional[str] = None ): """Background task to create an instance and track progress""" from services.lxd_client import get_lxd_client @@ -202,6 +203,29 @@ def create_instance_background( else: client.containers.create(config_data, wait=True) + # Apply LXD profile if specified (only non-resource settings) + if lxd_profile: + try: + creation_tasks[task_id]["message"] = f"Applying profile '{lxd_profile}'..." + profile = client.profiles.get(lxd_profile) + instance = client.instances.get(name) + # Merge profile config, but skip resource limits (cpu, memory) since those are from form + skip_keys = {"limits.cpu", "limits.memory"} + for key, value in profile.config.items(): + if key not in instance.config and key not in skip_keys: + instance.config[key] = value + # Merge profile devices, but skip root disk size since that's from form + for dev_name, dev_config in profile.devices.items(): + if dev_name not in instance.devices: + # Skip root device size override + if dev_name == "root" and "size" in dev_config: + continue + instance.devices[dev_name] = dev_config + instance.save() + except Exception as profile_error: + # Profile application failed, but instance was created + creation_tasks[task_id]["message"] = f"Instance created, but profile '{lxd_profile}' failed: {str(profile_error)}" + creation_tasks[task_id]["progress"] = 90 creation_tasks[task_id]["message"] = "Finalizing instance..." time.sleep(1) @@ -240,13 +264,14 @@ def start_creation_task( cloud_init: Optional[str] = None, vm_swap: int = 2, vm_username: str = "ubuntu", - image_fingerprint: Optional[str] = None + image_fingerprint: Optional[str] = None, + lxd_profile: Optional[str] = None ) -> str: """Start a new instance creation task and return task ID""" task_id = str(uuid.uuid4()) thread = threading.Thread( target=InstanceTaskService.create_instance_background, - args=(task_id, name, cpu, ram, disk, instance_type, lxd_settings, cloud_init, vm_swap, vm_username, image_fingerprint) + args=(task_id, name, cpu, ram, disk, instance_type, lxd_settings, cloud_init, vm_swap, vm_username, image_fingerprint, lxd_profile) ) thread.daemon = True thread.start() diff --git a/static/js/dashboard.js b/static/js/dashboard.js index ef9be74..524bfda 100644 --- a/static/js/dashboard.js +++ b/static/js/dashboard.js @@ -1,78 +1,79 @@ // Instance creation with progress tracking let createPollingInterval = null; -// ============== LXD Profile Picker ============== +// ============== Classroom Picker ============== -let _profileCache = null; // { name -> { cpu, memory, disk, has_cloud_init } } +let _classroomCache = null; // { id -> { name, image_type, lxd_profile, ... } } -async function loadProfiles() { +async function loadClassrooms() { try { - const res = await fetch('/api/lxd/profiles'); + const res = await fetch('/api/classrooms'); const data = await res.json(); - if (!data.success || !data.profiles.length) return; + if (!data.success || !data.classrooms.length) return; - _profileCache = {}; - data.profiles.forEach(p => { _profileCache[p.name] = p; }); + _classroomCache = {}; + data.classrooms.forEach(c => { _classroomCache[c.id] = c; }); const selectors = [ - document.getElementById('instance_profile'), - document.getElementById('bulk_profile'), + document.getElementById('instance_classroom'), + document.getElementById('bulk_classroom'), ]; selectors.forEach(sel => { if (!sel) return; - // Keep the "— manual —" placeholder, clear the rest + // Keep the placeholder option, clear the rest while (sel.options.length > 1) sel.remove(1); - data.profiles.forEach(p => { + data.classrooms.forEach(c => { const opt = document.createElement('option'); - opt.value = p.name; - const cpuLabel = p.cpu ? `${p.cpu}c` : ''; - const ramLabel = p.memory ? `${p.memory}G` : ''; - const diskLabel = p.disk ? `${p.disk}G` : ''; - const specs = [cpuLabel, ramLabel, diskLabel].filter(Boolean).join(' / '); - opt.textContent = specs ? `${p.name} (${specs})` : p.name; - if (p.has_cloud_init) opt.textContent += ' ☁'; + opt.value = c.id; + const typeIcon = c.image_type === 'virtual-machine' ? '🖥️' : '📦'; + const profileInfo = c.lxd_profile ? ` • ${c.lxd_profile}` : ''; + opt.textContent = `${typeIcon} ${c.name}${profileInfo}`; sel.appendChild(opt); }); }); } catch(e) { - console.warn('Could not load LXD profiles:', e.message); + console.warn('Could not load classrooms:', e.message); } } /** - * Apply selected profile defaults to form fields. + * Apply selected classroom settings to form fields. * @param {'instance'|'bulk'} context */ -function applyProfile(context) { - if (!_profileCache) return; +function applyClassroom(context) { + if (!_classroomCache) return; const prefix = context === 'bulk' ? 'bulk' : 'instance'; const selEl = document.getElementById( - context === 'bulk' ? 'bulk_profile' : 'instance_profile' + context === 'bulk' ? 'bulk_classroom' : 'instance_classroom' ); if (!selEl) return; - const profileName = selEl.value; - if (!profileName) return; // "— manual —" selected, leave fields as-is - - const p = _profileCache[profileName]; - if (!p) return; - - const cpuEl = document.getElementById(prefix === 'bulk' ? 'bulk_cpu' : 'instance_cpu'); - const ramEl = document.getElementById(prefix === 'bulk' ? 'bulk_ram' : 'instance_ram'); - const diskEl = document.getElementById(prefix === 'bulk' ? 'bulk_disk' : 'instance_disk'); - - if (p.cpu != null && cpuEl) cpuEl.value = p.cpu; - if (p.memory != null && ramEl) ramEl.value = p.memory; - if (p.disk != null && diskEl) diskEl.value = p.disk; -} - -/** - * Handle instance type change - just a placeholder for future use - */ -function onInstanceTypeChange() { - // Could be used to update UI based on instance type in the future + const classroomId = selEl.value; + if (!classroomId) return; // No classroom selected, leave fields as-is + + const c = _classroomCache[classroomId]; + if (!c) return; + + // If classroom has an LXD profile, fetch its details and populate CPU/RAM/Disk + if (c.lxd_profile) { + fetch(`/api/lxd/profiles/${encodeURIComponent(c.lxd_profile)}`) + .then(res => res.json()) + .then(data => { + if (data.success && data.profile) { + const p = data.profile; + const cpuEl = document.getElementById(prefix === 'bulk' ? 'bulk_cpu' : 'instance_cpu'); + const ramEl = document.getElementById(prefix === 'bulk' ? 'bulk_ram' : 'instance_ram'); + const diskEl = document.getElementById(prefix === 'bulk' ? 'bulk_disk' : 'instance_disk'); + + if (p.cpu != null && cpuEl) cpuEl.value = p.cpu; + if (p.memory != null && ramEl) ramEl.value = p.memory; + if (p.disk != null && diskEl) diskEl.value = p.disk; + } + }) + .catch(err => console.warn('Failed to fetch profile details:', err)); + } } // Live search for instances table @@ -147,7 +148,7 @@ function initLiveSearch() { // Initialize on page load document.addEventListener('DOMContentLoaded', function() { initLiveSearch(); - loadProfiles(); + loadClassrooms(); const form = document.getElementById('create-instance-form'); if (form) { form.addEventListener('submit', async function(e) { @@ -159,13 +160,27 @@ document.addEventListener('DOMContentLoaded', function() { const progressText = document.getElementById('create-progress-text'); const resultDiv = document.getElementById('create-result'); + // Get classroom and derive type from it + const classroomEl = form.instance_classroom; + const classroomId = classroomEl.value; + let instanceType = 'virtual-machine'; // default + let lxdProfile = null; + + if (classroomId && _classroomCache && _classroomCache[classroomId]) { + const classroom = _classroomCache[classroomId]; + instanceType = classroom.image_type || 'virtual-machine'; + lxdProfile = classroom.lxd_profile; + } + // Get form values const formData = { name: form.instance_name.value.trim(), cpu: parseInt(form.instance_cpu.value), ram: parseInt(form.instance_ram.value), disk: parseInt(form.instance_disk.value), - type: form.instance_type.value + type: instanceType, + classroom_id: classroomId || null, + lxd_profile: lxdProfile }; // Disable form during creation @@ -505,19 +520,33 @@ async function checkBulkPreflight() { async function startBulkCreate() { const namesText = document.getElementById('bulk_names').value.trim(); - + if (!namesText) { alert('Please enter at least one instance name'); return; } + // Get classroom and derive type from it + const classroomEl = document.getElementById('bulk_classroom'); + const classroomId = classroomEl.value; + let instanceType = 'virtual-machine'; // default + let lxdProfile = null; + + if (classroomId && _classroomCache && _classroomCache[classroomId]) { + const classroom = _classroomCache[classroomId]; + instanceType = classroom.image_type || 'virtual-machine'; + lxdProfile = classroom.lxd_profile; + } + // Expand patterns server-side by sending the raw pattern text const formData = { names: namesText, // Send raw pattern (e.g., "vm-{01-03}") cpu: parseInt(document.getElementById('bulk_cpu').value), ram: parseInt(document.getElementById('bulk_ram').value), disk: parseInt(document.getElementById('bulk_disk').value), - type: document.getElementById('bulk_type').value + type: instanceType, + classroom_id: classroomId || null, + lxd_profile: lxdProfile }; // Close the bulk create modal and show progress modal diff --git a/templates/admin/dashboard.html b/templates/admin/dashboard.html index a427209..73bd5b4 100644 --- a/templates/admin/dashboard.html +++ b/templates/admin/dashboard.html @@ -147,19 +147,10 @@
-
- - -
-
- - +
@@ -486,33 +477,24 @@
- - +
-
+
-
+
-
+
-
- - -
From 85cd303a15075ed64901d9f546bae9f252e6f18d Mon Sep 17 00:00:00 2001 From: Saiful Amin Date: Thu, 26 Mar 2026 09:11:11 +0000 Subject: [PATCH 09/34] refactor: change default instance type from virtual-machine to container MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update the default instance type across models, API routes, and UI to use "container" instead of "virtual-machine". Add type display elements in the dashboard forms to show the current instance type with appropriate icons (📦 Container, 🖥️ VM), and update the JavaScript to properly sync the type field from classroom selection. --- core/models.py | 2 +- routes/classrooms.py | 2 +- routes/instances.py | 4 ++-- routes/settings.py | 2 +- static/js/dashboard.js | 27 ++++++++++++++++++++------- templates/admin/dashboard.html | 18 ++++++++++++++---- 6 files changed, 39 insertions(+), 16 deletions(-) diff --git a/core/models.py b/core/models.py index a64d006..cb12db4 100644 --- a/core/models.py +++ b/core/models.py @@ -31,7 +31,7 @@ class Classroom(Base): id = Column(Integer, primary_key=True, index=True) name = Column(String, unique=True, index=True, nullable=False) # Classroom name (e.g., "CS101", "Data Science Lab") username = Column(String, default="ubuntu") # Default username for instances - image_type = Column(String, default="virtual-machine") # "virtual-machine" or "container" + image_type = Column(String, default="container") # "container" or "virtual-machine" lxd_profile = Column(String, nullable=True) # LXD profile name (references profiles[].name from LXD) image_fingerprint = Column(String, nullable=True) # LXD image fingerprint image_alias = Column(String, nullable=True) # Image alias (e.g., "ubuntu/24.04") diff --git a/routes/classrooms.py b/routes/classrooms.py index 1ebaeed..7e6a31f 100644 --- a/routes/classrooms.py +++ b/routes/classrooms.py @@ -135,7 +135,7 @@ async def create_classroom(request: Request, db: Session = Depends(get_db)): classroom = Classroom( name=name, username=data.get("username", "ubuntu"), - image_type=data.get("image_type", "virtual-machine"), + image_type=data.get("image_type", "container"), lxd_profile=data.get("lxd_profile"), image_fingerprint=data.get("image_fingerprint"), image_alias=data.get("image_alias"), diff --git a/routes/instances.py b/routes/instances.py index 76abc38..3881b76 100644 --- a/routes/instances.py +++ b/routes/instances.py @@ -61,7 +61,7 @@ async def create_instance( cpu = data.get("cpu") ram = data.get("ram") disk = data.get("disk") - instance_type = data.get("type", "virtual-machine") + instance_type = data.get("type", "container") # Validate instance name is_valid, error = validate_instance_name(name) @@ -298,7 +298,7 @@ async def bulk_create_instances( cpu = data.get("cpu", 2) ram = data.get("ram", 4) disk = data.get("disk", 20) - instance_type = data.get("type", "virtual-machine") + instance_type = data.get("type", "container") is_valid, error = validate_positive_integer(cpu, "CPU", min_val=1, max_val=128) if not is_valid: diff --git a/routes/settings.py b/routes/settings.py index caa8b37..1f658c3 100644 --- a/routes/settings.py +++ b/routes/settings.py @@ -179,7 +179,7 @@ async def generate_certificate(request: Request): @router.get("/settings/vm/images") async def get_available_images( db: Session = Depends(get_db), - instance_type: str = "virtual-machine" + instance_type: str = "container" ): """Get available LXD images for VM or container creation""" lxd_service = LXDService(db) diff --git a/static/js/dashboard.js b/static/js/dashboard.js index 524bfda..6d5aa75 100644 --- a/static/js/dashboard.js +++ b/static/js/dashboard.js @@ -51,11 +51,26 @@ function applyClassroom(context) { if (!selEl) return; const classroomId = selEl.value; - if (!classroomId) return; // No classroom selected, leave fields as-is + if (!classroomId) { + // No classroom selected, show default type + const typeDisplay = document.getElementById(prefix + '_type_display'); + const typeInput = document.getElementById(prefix + '_type'); + if (typeDisplay) typeDisplay.textContent = '📦 Container'; + if (typeInput) typeInput.value = 'container'; + return; + } const c = _classroomCache[classroomId]; if (!c) return; + // Update instance type display + const typeLabel = c.image_type === 'virtual-machine' ? 'VM' : 'Container'; + const typeIcon = c.image_type === 'virtual-machine' ? '🖥️' : '📦'; + const typeDisplay = document.getElementById(prefix + '_type_display'); + const typeInput = document.getElementById(prefix + '_type'); + if (typeDisplay) typeDisplay.textContent = typeIcon + ' ' + typeLabel; + if (typeInput) typeInput.value = c.image_type || 'container'; + // If classroom has an LXD profile, fetch its details and populate CPU/RAM/Disk if (c.lxd_profile) { fetch(`/api/lxd/profiles/${encodeURIComponent(c.lxd_profile)}`) @@ -163,12 +178,11 @@ document.addEventListener('DOMContentLoaded', function() { // Get classroom and derive type from it const classroomEl = form.instance_classroom; const classroomId = classroomEl.value; - let instanceType = 'virtual-machine'; // default + const instanceType = form.instance_type.value; // From hidden field updated by applyClassroom let lxdProfile = null; - + if (classroomId && _classroomCache && _classroomCache[classroomId]) { const classroom = _classroomCache[classroomId]; - instanceType = classroom.image_type || 'virtual-machine'; lxdProfile = classroom.lxd_profile; } @@ -529,12 +543,11 @@ async function startBulkCreate() { // Get classroom and derive type from it const classroomEl = document.getElementById('bulk_classroom'); const classroomId = classroomEl.value; - let instanceType = 'virtual-machine'; // default + const instanceType = document.getElementById('bulk_type').value; // From hidden field updated by applyClassroom let lxdProfile = null; - + if (classroomId && _classroomCache && _classroomCache[classroomId]) { const classroom = _classroomCache[classroomId]; - instanceType = classroom.image_type || 'virtual-machine'; lxdProfile = classroom.lxd_profile; } diff --git a/templates/admin/dashboard.html b/templates/admin/dashboard.html index 73bd5b4..80564af 100644 --- a/templates/admin/dashboard.html +++ b/templates/admin/dashboard.html @@ -147,12 +147,17 @@
-
+
+
+ +
📦 Container
+ +
@@ -483,15 +488,20 @@
-
+
+ +
📦 Container
+ +
+
-
+
-
+
From f70c5326dd327a7d01db8e713a6458afa162dc63 Mon Sep 17 00:00:00 2001 From: Saiful Amin Date: Thu, 26 Mar 2026 09:21:57 +0000 Subject: [PATCH 10/34] refactor(ui): improve instance type labeling and dark mode support Update form labels from "Type" and "Image Type" to "Instance Type" with contextual help links to clarify the distinction between containers and virtual machines. Add dark mode styling for form-control-plaintext elements in admin interface. --- static/css/admin.css | 4 ++++ templates/admin/classrooms.html | 6 ++++-- templates/admin/dashboard.html | 18 +++++++++++------- 3 files changed, 19 insertions(+), 9 deletions(-) diff --git a/static/css/admin.css b/static/css/admin.css index 2cf6eba..e07c2ea 100644 --- a/static/css/admin.css +++ b/static/css/admin.css @@ -236,6 +236,10 @@ body.dark-mode .text-muted { color: #888 !important; } +body.dark-mode .form-control-plaintext { + color: #eaeaea; +} + body.dark-mode .btn-close { filter: invert(1); } diff --git a/templates/admin/classrooms.html b/templates/admin/classrooms.html index 02f2000..da89bbf 100644 --- a/templates/admin/classrooms.html +++ b/templates/admin/classrooms.html @@ -139,10 +139,12 @@
- +
diff --git a/templates/admin/dashboard.html b/templates/admin/dashboard.html index 80564af..8203a9e 100644 --- a/templates/admin/dashboard.html +++ b/templates/admin/dashboard.html @@ -143,19 +143,21 @@
-
+
-
+
-
- -
📦 Container
+
+ +
📦 Container
@@ -489,8 +491,10 @@
- -
📦 Container
+ +
📦 Container
From 3a75984fde0b3262939bf37951c33b5182a48cd4 Mon Sep 17 00:00:00 2001 From: Saiful Amin Date: Thu, 26 Mar 2026 11:03:07 +0000 Subject: [PATCH 11/34] refactor(cloud-init): separate VM and container cloud-init templates Refactor cloud-init service to support distinct templates for VMs and containers. Remove vm_swap parameter as swap configuration is now hardcoded in VM template. Update API endpoint to accept template_type parameter for loading appropriate templates. Update UI to provide separate buttons for loading VM and container default templates. --- routes/instances.py | 2 -- routes/settings.py | 16 +++++++---- services/bulk_service.py | 8 ++---- services/cloud_init_service.py | 51 ++++++++++++++++++++++++--------- services/instance_tasks.py | 16 +++++------ static/js/classrooms.js | 4 +-- templates/admin/classrooms.html | 32 ++++++++++++++------- 7 files changed, 80 insertions(+), 49 deletions(-) diff --git a/routes/instances.py b/routes/instances.py index 3881b76..66f2a45 100644 --- a/routes/instances.py +++ b/routes/instances.py @@ -138,7 +138,6 @@ async def create_instance( instance_type=instance_type, lxd_settings=lxd_settings, cloud_init=cloud_init, - vm_swap=2, # Default swap, can be set in cloud-init vm_username=vm_username, image_fingerprint=image_fingerprint, lxd_profile=lxd_profile @@ -367,7 +366,6 @@ async def bulk_create_instances( instance_type=instance_type, lxd_settings=lxd_settings, cloud_init=cloud_init, - vm_swap=2, # Default swap, can be set in cloud-init vm_username=vm_username, image_fingerprint=image_fingerprint, lxd_profile=lxd_profile diff --git a/routes/settings.py b/routes/settings.py index 1f658c3..86cda28 100644 --- a/routes/settings.py +++ b/routes/settings.py @@ -246,11 +246,17 @@ async def get_available_images( }) -@router.get("/settings/vm/template") -async def get_vm_cloud_init_template(): - """Return the default cloud-init template text.""" - from services.cloud_init_service import DEFAULT_CLOUD_INIT_TEMPLATE - return JSONResponse({"success": True, "template": DEFAULT_CLOUD_INIT_TEMPLATE}) +@router.get("/settings/cloud-init/template") +async def get_default_cloud_init_template(template_type: str = "container"): + """Return the default cloud-init template text for VM or Container.""" + from services.cloud_init_service import DEFAULT_CLOUD_INIT_TEMPLATE_VM, DEFAULT_CLOUD_INIT_TEMPLATE_CONTAINER + + if template_type == "container": + template = DEFAULT_CLOUD_INIT_TEMPLATE_CONTAINER + else: + template = DEFAULT_CLOUD_INIT_TEMPLATE_VM + + return JSONResponse({"success": True, "template": template}) @router.get("/settings/connection-templates") diff --git a/services/bulk_service.py b/services/bulk_service.py index f45e3a6..30b374b 100644 --- a/services/bulk_service.py +++ b/services/bulk_service.py @@ -208,14 +208,13 @@ def bulk_create_instances( instance_type: str, lxd_settings: dict, cloud_init: Optional[str] = None, - vm_swap: int = 2, vm_username: str = "ubuntu", image_fingerprint: Optional[str] = None, lxd_profile: Optional[str] = None ): """ Background task to create multiple instances. - + Args: op_id: Operation ID for tracking instance_names: List of instance names to create @@ -225,7 +224,6 @@ def bulk_create_instances( instance_type: "virtual-machine" or "container" lxd_settings: LXD connection settings cloud_init: Cloud-init template - vm_swap: Swap size in GB vm_username: Default username for VMs image_fingerprint: Optional LXD image fingerprint """ @@ -263,7 +261,6 @@ def bulk_create_instances( instance_type=instance_type, lxd_settings=lxd_settings, cloud_init=cloud_init, - vm_swap=vm_swap, vm_username=vm_username, image_fingerprint=image_fingerprint, lxd_profile=lxd_profile @@ -336,7 +333,6 @@ def start_bulk_create( instance_type: str, lxd_settings: dict, cloud_init: Optional[str] = None, - vm_swap: int = 2, vm_username: str = "ubuntu", image_fingerprint: Optional[str] = None, lxd_profile: Optional[str] = None @@ -352,7 +348,7 @@ def start_bulk_create( thread = threading.Thread( target=BulkOperationService.bulk_create_instances, args=(op_id, instance_names, cpu, ram, disk, instance_type, - lxd_settings, cloud_init, vm_swap, vm_username, image_fingerprint, lxd_profile) + lxd_settings, cloud_init, vm_username, image_fingerprint, lxd_profile) ) thread.daemon = True thread.start() diff --git a/services/cloud_init_service.py b/services/cloud_init_service.py index 25d18c2..6560909 100644 --- a/services/cloud_init_service.py +++ b/services/cloud_init_service.py @@ -1,9 +1,9 @@ -"""Cloud-init template service for VM configuration""" +"""Cloud-init template service for VM/container configuration""" from core.config import settings -# Default cloud-init template -DEFAULT_CLOUD_INIT_TEMPLATE = """#cloud-config +# Default cloud-init template for VMs (includes swap configuration) +DEFAULT_CLOUD_INIT_TEMPLATE_VM = """#cloud-config # Default user configuration users: - name: {username} @@ -21,9 +21,9 @@ - zip - plocate -# Add swap file +# Add swap file (2GB) runcmd: - - [ fallocate, -l, '{swap_size}G', /swapfile ] + - [ fallocate, -l, '2G', /swapfile ] - [ chmod, 600, /swapfile ] - [ mkswap, /swapfile ] - [ swapon, /swapfile ] @@ -31,14 +31,38 @@ """ -def get_cloud_init_template(custom_template: str = None, public_key: str = None, swap_size: int = 2, username: str = None) -> str: +# Default cloud-init template for Containers (includes MOTD) +DEFAULT_CLOUD_INIT_TEMPLATE_CONTAINER = """#cloud-config +# Default user configuration +users: + - name: {username} + sudo: ALL=(ALL) NOPASSWD:ALL + shell: /bin/bash + ssh_authorized_keys: + - {public_key} + +# Update packages on first boot +package_update: true +package_upgrade: false + +# Install additional packages +packages: + - zip + - plocate + +# Set custom MOTD +motd: | + Welcome to the server. + This message is displayed to all users when they log in. +""" + +def get_cloud_init_template(custom_template: str = None, public_key: str = None, username: str = None) -> str: """ Get cloud-init template with placeholders replaced. Args: custom_template: Custom template from database. If None, uses default template. public_key: SSH public key to use. If None, uses value from settings. - swap_size: Swap size in GiB. Default is 2. username: Username for the VM. If None, uses value from settings. Returns: @@ -53,29 +77,28 @@ def get_cloud_init_template(custom_template: str = None, public_key: str = None, # Replace placeholders with values return template.format( username=vm_username, - public_key=ssh_public_key, - swap_size=swap_size + public_key=ssh_public_key ) def validate_cloud_init_template(template: str) -> tuple[bool, str]: """ Validate that a cloud-init template has the required placeholders. - + Args: template: Template string to validate - + Returns: Tuple of (is_valid, error_message) """ required_placeholders = ['{username}', '{public_key}'] missing = [] - + for placeholder in required_placeholders: if placeholder not in template: missing.append(placeholder) - + if missing: return False, f"Missing placeholders: {', '.join(missing)}" - + return True, "" diff --git a/services/instance_tasks.py b/services/instance_tasks.py index b1e8e92..5046fa6 100644 --- a/services/instance_tasks.py +++ b/services/instance_tasks.py @@ -4,7 +4,6 @@ import uuid from typing import Dict, Any, Optional -from services.cloud_init_service import get_cloud_init_template from services.ssh_key_service import generate_and_save_keys @@ -43,7 +42,6 @@ def create_instance_background( instance_type: str, lxd_settings: dict, cloud_init: Optional[str] = None, - vm_swap: int = 2, vm_username: str = "ubuntu", image_fingerprint: Optional[str] = None, lxd_profile: Optional[str] = None @@ -151,14 +149,15 @@ def create_instance_background( "limits.memory": f"{ram}GiB", } - # Add cloud-init user-data if provided (shared logic) + # Add cloud-init user-data if provided if cloud_init: if ssh_keys and ssh_keys.get("public_key"): + # Replace placeholders in cloud-init template + from services.cloud_init_service import get_cloud_init_template instance_config["user.user-data"] = get_cloud_init_template( - cloud_init, - ssh_keys["public_key"], - vm_swap, - vm_username + custom_template=cloud_init, + public_key=ssh_keys["public_key"], + username=vm_username ) else: instance_config["user.user-data"] = cloud_init @@ -262,7 +261,6 @@ def start_creation_task( instance_type: str, lxd_settings: dict, cloud_init: Optional[str] = None, - vm_swap: int = 2, vm_username: str = "ubuntu", image_fingerprint: Optional[str] = None, lxd_profile: Optional[str] = None @@ -271,7 +269,7 @@ def start_creation_task( task_id = str(uuid.uuid4()) thread = threading.Thread( target=InstanceTaskService.create_instance_background, - args=(task_id, name, cpu, ram, disk, instance_type, lxd_settings, cloud_init, vm_swap, vm_username, image_fingerprint, lxd_profile) + args=(task_id, name, cpu, ram, disk, instance_type, lxd_settings, cloud_init, vm_username, image_fingerprint, lxd_profile) ) thread.daemon = True thread.start() diff --git a/static/js/classrooms.js b/static/js/classrooms.js index 21f6f3d..b599542 100644 --- a/static/js/classrooms.js +++ b/static/js/classrooms.js @@ -543,9 +543,9 @@ function hideNewProfileForm() { } } -async function loadDefaultCloudInit(targetId) { +async function loadDefaultCloudInit(targetId, templateType = 'container') { try { - const res = await fetch('/settings/vm/template'); + const res = await fetch(`/settings/cloud-init/template?template_type=${templateType}`); const data = await res.json(); if (data.success) { document.getElementById(targetId).value = data.template; diff --git a/templates/admin/classrooms.html b/templates/admin/classrooms.html index da89bbf..4173eda 100644 --- a/templates/admin/classrooms.html +++ b/templates/admin/classrooms.html @@ -253,15 +253,20 @@
-
-
From 1c5a2749391b930e4a5c07659faeaf605980b48f Mon Sep 17 00:00:00 2001 From: Saiful Amin Date: Thu, 26 Mar 2026 11:09:59 +0000 Subject: [PATCH 12/34] refactor(classrooms): move cloud-init and SSH template endpoints from settings API - Relocated /settings/cloud-init/template to /classrooms/cloud-init/template - Relocated /settings/connection-templates to /classrooms/connection-templates - Updated frontend fetch calls to use new classroom endpoints - Incremented static JS version to v=3 --- routes/classrooms.py | 26 ++++++++++++++++++++++++++ routes/settings.py | 23 ----------------------- static/js/classrooms.js | 4 ++-- templates/admin/classrooms.html | 2 +- 4 files changed, 29 insertions(+), 26 deletions(-) diff --git a/routes/classrooms.py b/routes/classrooms.py index 7e6a31f..f2bd026 100644 --- a/routes/classrooms.py +++ b/routes/classrooms.py @@ -58,6 +58,32 @@ async def classrooms_page( "username": user.username, }) +# ============================================================ +# Cloud-init template and SSH config template routes (API) +# ============================================================ + +@router.get("/classrooms/cloud-init/template") +async def get_default_cloud_init_template(template_type: str = "container"): + """Return the default cloud-init template text for VM or Container.""" + from services.cloud_init_service import DEFAULT_CLOUD_INIT_TEMPLATE_VM, DEFAULT_CLOUD_INIT_TEMPLATE_CONTAINER + + if template_type == "container": + template = DEFAULT_CLOUD_INIT_TEMPLATE_CONTAINER + else: + template = DEFAULT_CLOUD_INIT_TEMPLATE_VM + + return JSONResponse({"success": True, "template": template}) + + +@router.get("/classrooms/connection-templates") +async def get_connection_templates(): + """Get default SSH config template""" + from services.ssh_config_service import DEFAULT_SSH_CONFIG_TEMPLATE + return JSONResponse({ + "success": True, + "ssh_config_template": DEFAULT_SSH_CONFIG_TEMPLATE + }) + # ============================================================ # Classroom CRUD (JSON API) - moved from settings.py diff --git a/routes/settings.py b/routes/settings.py index 86cda28..58bd86a 100644 --- a/routes/settings.py +++ b/routes/settings.py @@ -244,26 +244,3 @@ async def get_available_images( "success": False, "message": "Failed to fetch images" }) - - -@router.get("/settings/cloud-init/template") -async def get_default_cloud_init_template(template_type: str = "container"): - """Return the default cloud-init template text for VM or Container.""" - from services.cloud_init_service import DEFAULT_CLOUD_INIT_TEMPLATE_VM, DEFAULT_CLOUD_INIT_TEMPLATE_CONTAINER - - if template_type == "container": - template = DEFAULT_CLOUD_INIT_TEMPLATE_CONTAINER - else: - template = DEFAULT_CLOUD_INIT_TEMPLATE_VM - - return JSONResponse({"success": True, "template": template}) - - -@router.get("/settings/connection-templates") -async def get_connection_templates(): - """Get default SSH config template""" - from services.ssh_config_service import DEFAULT_SSH_CONFIG_TEMPLATE - return JSONResponse({ - "success": True, - "ssh_config_template": DEFAULT_SSH_CONFIG_TEMPLATE - }) diff --git a/static/js/classrooms.js b/static/js/classrooms.js index b599542..0c5825f 100644 --- a/static/js/classrooms.js +++ b/static/js/classrooms.js @@ -329,7 +329,7 @@ function showClassroomAlert(type, message) { async function loadDefaultSSHConfig(targetId) { try { - const res = await fetch('/settings/connection-templates'); + const res = await fetch('/classrooms/connection-templates'); const data = await res.json(); if (data.success) { document.getElementById(targetId).value = data.ssh_config_template; @@ -545,7 +545,7 @@ function hideNewProfileForm() { async function loadDefaultCloudInit(targetId, templateType = 'container') { try { - const res = await fetch(`/settings/cloud-init/template?template_type=${templateType}`); + const res = await fetch(`/classrooms/cloud-init/template?template_type=${templateType}`); const data = await res.json(); if (data.success) { document.getElementById(targetId).value = data.template; diff --git a/templates/admin/classrooms.html b/templates/admin/classrooms.html index 4173eda..fbb380e 100644 --- a/templates/admin/classrooms.html +++ b/templates/admin/classrooms.html @@ -339,5 +339,5 @@ {% endblock %} {% block extra_js %} - + {% endblock %} From fd3ce7bb16b54a864db64505474e61502a7faa06 Mon Sep 17 00:00:00 2001 From: Saiful Amin Date: Thu, 26 Mar 2026 11:14:35 +0000 Subject: [PATCH 13/34] feat(ui): reorder cloud-init load buttons and add placeholder documentation Added helpful text explaining that `{username}` and `{public_key}` placeholders are rendered at instance creation time, and swapped the button order to show container option first for consistency --- templates/admin/classrooms.html | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/templates/admin/classrooms.html b/templates/admin/classrooms.html index fbb380e..21003d8 100644 --- a/templates/admin/classrooms.html +++ b/templates/admin/classrooms.html @@ -257,12 +257,12 @@ Cloud-init template (leave blank to remove)
- +
@@ -309,15 +309,16 @@ Cloud-init template (optional)
- +
+ Placeholders {username}, {public_key} are rendered at instance creation time.
@@ -27,7 +27,7 @@
-
+
Classrooms @@ -45,7 +45,7 @@
-
+
@@ -77,7 +77,7 @@
- + @@ -88,7 +88,7 @@ - Choose from available LXD images + Choose from available images
@@ -148,7 +148,7 @@
- + @@ -159,7 +159,7 @@ - Choose from available LXD images + Choose from available images
@@ -203,7 +203,7 @@
-
+
Profiles @@ -221,7 +221,7 @@
-
+
From f548b9c464fb728695adbb4b4c37a980271d8feb Mon Sep 17 00:00:00 2001 From: Saiful Amin Date: Thu, 26 Mar 2026 12:11:39 +0000 Subject: [PATCH 16/34] refactor(ui): restructure classrooms form layout for improved organization --- templates/admin/classrooms.html | 200 ++++++++++++++++---------------- 1 file changed, 102 insertions(+), 98 deletions(-) diff --git a/templates/admin/classrooms.html b/templates/admin/classrooms.html index 858085b..1c83d35 100644 --- a/templates/admin/classrooms.html +++ b/templates/admin/classrooms.html @@ -59,61 +59,61 @@
-
- - -
-
- - -
-
-
-
- - -
-
- - +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + Choose from available images +
+
+ + + + +
-
-
- - - Choose from available images -
-
- - - - -
-
- - - Available placeholders: {vm_name}, {host_ip}, {username}, {private_key_filename}, {vm_hostname} -

- Use LocalForward - to access services running on the VM from your host machine. -

+ Available placeholders: {vm_name}, {host_ip}, {username}, {private_key_filename}, {vm_hostname} +

+ Use LocalForward + to access services running on the VM from your host machine. +

+
-
-
- - -
-
- - +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + Choose from available images +
+
+ + + + +
-
-
-
-
-
- - - Choose from available images -
-
- - - - -
-
- - - Available placeholders: {vm_name}, {host_ip}, {username}, {private_key_filename}, {vm_hostname} + Available placeholders: {vm_name}, {host_ip}, {username}, {private_key_filename}, {vm_hostname} +

+ Use LocalForward + to access services running on the VM from your host machine. +

+
- +
+
+ +
+ + +
+
+ + Placeholders {username}, {public_key} are rendered at instance creation time.
- - Placeholders {username}, {public_key} are rendered at instance creation time.
+ @@ -286,44 +292,47 @@
- - -
-
- - -
-
-
-
- - -
-
- - -
-
- - +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
-
-
- -
- - +
+
+ +
+ + +
+
+ + Placeholders {username}, {public_key} are rendered at instance creation time.
- - Placeholders {username}, {public_key} are rendered at instance creation time.
+ From 7a9a1b8ff642db9493d53641c122951d0a59197b Mon Sep 17 00:00:00 2001 From: Saiful Amin Date: Thu, 26 Mar 2026 12:44:43 +0000 Subject: [PATCH 18/34] feat(bulk): add container density and overcommit support to bulk preflight checks Add instance type selection ("container" or "virtual-machine") and allow_overcommit parameter to bulk preflight checks. Containers now use a 4x density factor in resource calculations, allowing higher instance density on the same hardware. The preflight check also accounts for this density when calculating effective CPU/RAM requirements and provides more lenient overcommitment thresholds when explicitly enabled. --- routes/instances.py | 10 +++- services/bulk_service.py | 104 +++++++++++++++++++++++---------- static/js/dashboard.js | 33 ++++++++--- templates/admin/dashboard.html | 11 +++- 4 files changed, 113 insertions(+), 45 deletions(-) diff --git a/routes/instances.py b/routes/instances.py index 66f2a45..8a65ee6 100644 --- a/routes/instances.py +++ b/routes/instances.py @@ -215,6 +215,8 @@ async def bulk_preflight_check( cpu: int = 2, ram: int = 4, disk: int = 20, + type: str = "container", + allow_overcommit: bool = False, db: Session = Depends(get_db), user: AdminUser = Depends(require_auth) ): @@ -235,9 +237,11 @@ async def bulk_preflight_check( checks = BulkOperationService.check_preflight( db, instance_names=instance_names if instance_names else None, - cpu_per_vm=cpu, - ram_per_vm=ram, - disk_per_vm=disk + cpu_per_instance=cpu, + ram_per_instance=ram, + disk_per_instance=disk, + instance_type=type, + allow_overcommit=allow_overcommit ) return JSONResponse(checks) diff --git a/services/bulk_service.py b/services/bulk_service.py index 30b374b..e5f7c26 100644 --- a/services/bulk_service.py +++ b/services/bulk_service.py @@ -35,18 +35,21 @@ def cleanup_operation(op_id: str): del bulk_operations[op_id] @staticmethod - def check_preflight(db, instance_names: List[str] = None, - cpu_per_vm: int = 2, ram_per_vm: int = 4, - disk_per_vm: int = 20, required_disk_gb: float = 50) -> Dict[str, Any]: + def check_preflight(db, instance_names: List[str] = None, + cpu_per_instance: int = 2, ram_per_instance: int = 4, + disk_per_instance: int = 20, instance_type: str = "container", + allow_overcommit: bool = False, required_disk_gb: float = 50) -> Dict[str, Any]: """ Run pre-flight checks before bulk operations. Args: db: Database session instance_names: List of instance names to create (for capacity check) - cpu_per_vm: CPU cores per VM - ram_per_vm: RAM in GB per VM - disk_per_vm: Disk in GB per VM + cpu_per_instance: CPU cores per instance + ram_per_instance: RAM in GB per instance + disk_per_instance: Disk in GB per instance + instance_type: "container" or "virtual-machine" (affects density calculations) + allow_overcommit: If True, allow over-provisioning beyond normal limits required_disk_gb: Minimum required free disk space after creation Returns: @@ -59,6 +62,11 @@ def check_preflight(db, instance_names: List[str] = None, } num_instances = len(instance_names) if instance_names else 0 + is_container = instance_type == "container" + + # Container density factor: containers can be 4-5x more dense than VMs + # because they share the kernel and have less overhead + density_factor = 4.0 if is_container else 1.0 # Check LXD connection try: @@ -92,15 +100,23 @@ def check_preflight(db, instance_names: List[str] = None, # Calculate resource requirements if num_instances > 0: - total_cpu_needed = num_instances * cpu_per_vm - total_ram_needed = num_instances * ram_per_vm - total_disk_needed = num_instances * disk_per_vm + total_cpu_needed = num_instances * cpu_per_instance + total_ram_needed = num_instances * ram_per_instance + total_disk_needed = num_instances * disk_per_instance checks["resources_requested"] = { "instances": num_instances, "cpu": total_cpu_needed, "ram_gb": total_ram_needed, "disk_gb": total_disk_needed } + # Effective resource needs accounting for container density + effective_cpu_needed = total_cpu_needed / density_factor + effective_ram_needed = total_ram_needed / density_factor + checks["effective_resources"] = { + "cpu": round(effective_cpu_needed, 1), + "ram_gb": round(effective_ram_needed, 1), + "density_factor": density_factor + } # Check disk space try: @@ -141,21 +157,29 @@ def check_preflight(db, instance_names: List[str] = None, available_ram = psutil.virtual_memory().available / (1024 ** 3) checks["ram_total_gb"] = round(total_ram, 2) checks["ram_available_gb"] = round(available_ram, 2) - + if num_instances > 0: - ram_after = available_ram - total_ram_needed + # Use effective RAM for containers (lower due to density) + effective_ram = effective_ram_needed if is_container else total_ram_needed + ram_after = available_ram - effective_ram checks["ram_after_creation_gb"] = round(ram_after, 2) - + # Reserve 2GB for host system min_ram_after = 2.0 - + if ram_after < min_ram_after: - checks["errors"].append( - f"Insufficient RAM: {available_ram:.1f} GB available, " - f"{total_ram_needed} GB needed for {num_instances} VMs, " - f"only {ram_after:.1f} GB would remain (minimum {min_ram_after} GB for host recommended)" - ) - checks["passed"] = False + if allow_overcommit: + checks["warnings"].append( + f"⚠️ Over-committing RAM: {available_ram:.1f} GB available, " + f"{effective_ram:.1f} GB requested, only {ram_after:.1f} GB would remain" + ) + else: + checks["errors"].append( + f"Insufficient RAM: {available_ram:.1f} GB available, " + f"{effective_ram:.1f} GB needed for {num_instances} {'containers' if is_container else 'VMs'}, " + f"only {ram_after:.1f} GB would remain (minimum {min_ram_after} GB for host recommended)" + ) + checks["passed"] = False elif ram_after < min_ram_after * 2: checks["warnings"].append( f"RAM will be low after creation: {ram_after:.1f} GB remaining for host" @@ -170,21 +194,37 @@ def check_preflight(db, instance_names: List[str] = None, import psutil cpu_count = psutil.cpu_count(logical=True) checks["cpu_logical_cores"] = cpu_count - + if num_instances > 0: + # Use effective CPU for containers (lower due to density) + effective_cpu = effective_cpu_needed if is_container else total_cpu_needed # Allow overcommitment but warn if too aggressive - cpu_ratio = total_cpu_needed / cpu_count if cpu_count > 0 else 999 - - if cpu_ratio > 4: - checks["warnings"].append( - f"High CPU overcommitment: {total_cpu_needed} vCPUs requested " - f"on {cpu_count} core system ({cpu_ratio:.1f}x overcommit)" - ) - elif cpu_ratio > 2: - checks["warnings"].append( - f"Moderate CPU overcommitment: {total_cpu_needed} vCPUs " - f"on {cpu_count} core system ({cpu_ratio:.1f}x overcommit)" - ) + cpu_ratio = effective_cpu / cpu_count if cpu_count > 0 else 999 + + if allow_overcommit: + # More lenient thresholds when overcommit is allowed + if cpu_ratio > 8: + checks["warnings"].append( + f"⚠️ Very high CPU overcommitment: {effective_cpu:.1f} vCPUs requested " + f"on {cpu_count} core system ({cpu_ratio:.1f}x overcommit)" + ) + elif cpu_ratio > 4: + checks["warnings"].append( + f"⚠️ High CPU overcommitment: {effective_cpu:.1f} vCPUs requested " + f"on {cpu_count} core system ({cpu_ratio:.1f}x overcommit)" + ) + else: + # Normal thresholds + if cpu_ratio > 4: + checks["warnings"].append( + f"High CPU overcommitment: {effective_cpu:.1f} vCPUs requested " + f"on {cpu_count} core system ({cpu_ratio:.1f}x overcommit)" + ) + elif cpu_ratio > 2: + checks["warnings"].append( + f"Moderate CPU overcommitment: {effective_cpu:.1f} vCPUs " + f"on {cpu_count} core system ({cpu_ratio:.1f}x overcommit)" + ) except ImportError: checks["warnings"].append("psutil not installed - skipping CPU check") except Exception as e: diff --git a/static/js/dashboard.js b/static/js/dashboard.js index 6d5aa75..613246c 100644 --- a/static/js/dashboard.js +++ b/static/js/dashboard.js @@ -462,16 +462,20 @@ async function checkBulkPreflight() { const cpu = parseInt(document.getElementById('bulk_cpu').value); const ram = parseInt(document.getElementById('bulk_ram').value); const disk = parseInt(document.getElementById('bulk_disk').value); - + const instanceType = document.getElementById('bulk_type').value; + const allowOvercommit = document.getElementById('bulk_allow_overcommit').checked; + const resultDiv = document.getElementById('bulk-preflight-result'); resultDiv.innerHTML = '
Checking prerequisites...
'; - + try { const params = new URLSearchParams({ names: instanceNames.join(','), cpu: cpu.toString(), ram: ram.toString(), - disk: disk.toString() + disk: disk.toString(), + type: instanceType, + allow_overcommit: allowOvercommit.toString() }); const response = await fetch(`/instances/bulk/preflight?${params}`); @@ -487,12 +491,23 @@ async function checkBulkPreflight() { if (checks.passed) { html += '
All pre-flight checks passed!
'; - html += `
- Resources required:
- CPU: ${checks.resources_requested?.cpu || 0} vCPUs | - RAM: ${checks.resources_requested?.ram_gb || 0} GB | - Disk: ${checks.resources_requested?.disk_gb || 0} GB -
`; + + // Show effective resources for containers (with density factor) + if (checks.effective_resources && instanceType === 'container') { + html += `
+ Resources required (with ${checks.effective_resources.density_factor}x container density):
+ CPU: ${checks.effective_resources.cpu} vCPUs (effective) | + RAM: ${checks.effective_resources.ram_gb} GB (effective) | + Disk: ${checks.resources_requested?.disk_gb || 0} GB +
`; + } else { + html += `
+ Resources required:
+ CPU: ${checks.resources_requested?.cpu || 0} vCPUs | + RAM: ${checks.resources_requested?.ram_gb || 0} GB | + Disk: ${checks.resources_requested?.disk_gb || 0} GB +
`; + } document.getElementById('bulkCreateStartBtn').disabled = false; } else { html = '
Pre-flight checks failed:
    '; diff --git a/templates/admin/dashboard.html b/templates/admin/dashboard.html index 8203a9e..d927d8e 100644 --- a/templates/admin/dashboard.html +++ b/templates/admin/dashboard.html @@ -510,6 +510,15 @@
+
+ + + + Containers can run 4-5x denser than VMs. Enable this to allow higher CPU/RAM overcommitment ratios. + +