diff --git a/CHANGELOG.md b/CHANGELOG.md index f088d9c..220b8de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **Classroom Management** - Create reusable configurations with predefined images, LXD profiles, and SSH templates +- **LXD Profile Management** - Full CRUD UI for managing LXD profiles with CPU, RAM, disk, and cloud-init settings +- **Container Support** - Bulk creation now supports both VMs and containers with density-aware resource checks +- **Over-commit Option** - Allow resource over-commitment for high-density container deployments +- **Instance Type Display** - Shows selected instance type (VM/Container) in creation forms +- **Help Links** - Added informational links for LXD container vs VM selection + +### Changed +- **Refactored Settings** - Merged VM/Container defaults and SSH templates into unified Classroom model +- **Cloud-init Templates** - Separate default templates for VMs (with swap) and containers (with MOTD) +- **Pre-flight Checks** - Container density factor (4x) applied for more accurate resource estimation +- **Delete Confirmations** - Modal dialogs for deleting classrooms and profiles (consistent with dashboard) +- **Profile Protection** - Cannot delete LXD profiles in use by classrooms or the 'default' profile +- **Username Required** - Default username is now mandatory in classroom configuration + +### Removed +- **Swap Setting** - Removed dedicated swap field; swap now configured exclusively via cloud-init templates +- **Standalone Settings** - VM defaults, container defaults, and connection templates tabs removed from Settings + +### Security +- **Profile Deletion Protection** - Application-level foreign key constraint prevents deleting profiles in use + + +## [v0.2.0] - 2026-03-25 + ### Added - Pattern-based bulk VM creation (e.g., `vm-{01-05}`) - Pre-flight resource checks for bulk operations diff --git a/Caddyfile b/Caddyfile index 9c4f52e..0ce17ca 100644 --- a/Caddyfile +++ b/Caddyfile @@ -2,7 +2,7 @@ # # Prerequisites: # 1. Install Caddy: https://caddyserver.com/docs/install -# 2. Point your domain to this server (DNS A record) +# 2. Point your domain to this server (DNS A record or CNAME pointing to this server's IP) # 3. Ensure ports 80 and 443 are open # # Usage: @@ -15,7 +15,6 @@ sandbox.example.com { # Automatic HTTPS with Let's Encrypt reverse_proxy localhost:8000 - # Increase request body size for cloud-init ISO uploads request_body { max_size 1MB } @@ -34,7 +33,6 @@ sandbox.example.com { header { X-Content-Type-Options "nosniff" X-Frame-Options "DENY" - X-XSS-Protection "1; mode=block" Referrer-Policy "strict-origin-when-cross-origin" } diff --git a/README.md b/README.md index 8aaec7d..fa1b240 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ A web-based interface for managing LXD virtual machines and containers. Designed ## Features +- **Classroom Management** - Create reusable configurations with predefined images, LXD profiles, and SSH templates - **Bulk VM Creation** - Create multiple VMs at once with pre-flight resource checks - **One-Click Operations** - Start, stop, or delete all VMs in bulk - **SSH ProxyJump** - Secure SSH access with auto-generated jump user and SSH configs @@ -19,7 +20,7 @@ A web-based interface for managing LXD virtual machines and containers. Designed ## Requirements - Linux host with [LXD installed](https://canonical.com/lxd/install) and configured (`lxd init`) -- Python 3.10+ +- Python 3.10+ with `venv` module - 50GB+ free disk space (depending on VM count) ## Pre-setup @@ -35,7 +36,9 @@ This step will download the `ubuntu:24.04` image. The Sandbox app will only show ## Installation ```bash -git clone https://github.com/semanticlib/sandbox.git +cd /opt +sudo git clone https://github.com/semanticlib/sandbox.git +sudo chown -R $(whoami):$(whoami) sandbox cd sandbox python -m venv .venv source .venv/bin/activate @@ -43,27 +46,65 @@ pip install -r requirements.txt cp env.example .env SECRET_KEY=$(openssl rand -hex 32) sed -i "s/^SECRET_KEY=.*/SECRET_KEY=$SECRET_KEY/" .env -# Update the LXD Host IP in HOST_SERVER_IP variable in .env file -uvicorn main:app +``` + +Also update the `HOST_SERVER_IP` variable in `.env` file with your LXD host IP. + +**Test run** + +```bash +python main.py ``` **Secure Access using SSH Tunnel** -Create an SSH Tunnel to access the app. +From your local machine, create an SSH Tunnel to access the app. ```bash ssh -L 8000:localhost:8000 user@ ``` -Open `http://localhost:8000` in your browser +Open `http://localhost:8000` in your browser and proceed with the initial setup. + +## Production Deployment (Systemd) -**Custom Port:** +To run the application in production using `systemd`, use the following commands: -To use a different port, set `PORT` in `.env`: ```bash -PORT=9000 +export APP_DIR=/opt/sandbox # Adjust if you want to install in a different directory +export APP_USER=$(whoami) # Adjust if you want to run as a different user +sudo tee /etc/systemd/system/sandbox.service > /dev/null < [!IMPORTANT] > Auth cookies require HTTPS (`secure=True` flag). The login sessions won't persist without HTTPS. > Point any FQDN to your server and use Caddy for automatic SSL for your domain. See example [Caddyfile](Caddyfile) for reference. diff --git a/core/models.py b/core/models.py index a080aee..cb12db4 100644 --- a/core/models.py +++ b/core/models.py @@ -24,37 +24,16 @@ class LXDSettings(Base): verify_ssl = Column(Boolean, default=True) -class VMDefaultSettings(Base): - __tablename__ = "vm_default_settings" +class Classroom(Base): + """Classroom configuration - combines VM/Container defaults, image selection, and SSH config""" + __tablename__ = "classrooms" id = Column(Integer, primary_key=True, index=True) - username = Column(String, default="ubuntu") - cpu = Column(Integer, default=2) - memory = Column(Integer, default=4) - disk = Column(Integer, default=20) - swap = Column(Integer, default=2) + 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="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") image_description = Column(String, nullable=True) # Human-readable description - 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" - - id = Column(Integer, primary_key=True, index=True) ssh_config_template = Column(Text, nullable=True) # SSH config template with placeholders diff --git a/core/templates.py b/core/templates.py new file mode 100644 index 0000000..39cb11b --- /dev/null +++ b/core/templates.py @@ -0,0 +1,13 @@ +"""Shared Jinja2 templates configuration""" +from fastapi.templating import Jinja2Templates +from jinja2 import filters + +from core.config import settings + + +# Create a single shared templates instance +templates = Jinja2Templates(directory="templates") + +# Configure shared filters and globals +templates.env.globals['app_title'] = settings.APP_TITLE +templates.env.filters['filesizeformat'] = filters.do_filesizeformat diff --git a/main.py b/main.py index 76cf0e9..c8c28df 100644 --- a/main.py +++ b/main.py @@ -2,9 +2,8 @@ from contextlib import asynccontextmanager from fastapi import FastAPI from fastapi.staticfiles import StaticFiles -from fastapi.templating import Jinja2Templates -from jinja2 import filters +from core.templates import templates from core.database import engine, Base from core.config import settings @@ -29,20 +28,16 @@ async def lifespan(app: FastAPI): # Mount static files app.mount("/static", StaticFiles(directory="static"), name="static") -# Setup templates -templates = Jinja2Templates(directory="templates") -templates.env.filters['filesizeformat'] = filters.do_filesizeformat -templates.env.globals['app_title'] = settings.APP_TITLE - # ============== Include Routers ============== -from routes import auth, dashboard, instances, settings +from routes import auth, dashboard, instances, settings, classrooms app.include_router(auth.router) app.include_router(dashboard.router) app.include_router(instances.router) app.include_router(settings.router) +app.include_router(classrooms.router) # ============== Exception Handlers ============== diff --git a/requirements.txt b/requirements.txt index 14e899f..230d66b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,27 @@ -fastapi[standard] -pylxd -sqlalchemy -python-jose[cryptography] -bcrypt -psutil -python-dotenv -uvicorn[standard] \ No newline at end of file +# Core dependencies with pinned major versions for production stability +# Minor and patch versions can be updated for security patches and bug fixes + +# Web framework +fastapi[standard]>=0.115.0,<1.0.0 +starlette>=0.30.0,<1.0.0 # Required for template rendering compatibility + +# LXD API client +pylxd>=2.3.0,<3.0.0 + +# Database ORM +sqlalchemy>=2.0.0,<3.0.0 + +# JWT authentication +python-jose[cryptography]>=3.3.0,<4.0.0 + +# Password hashing +bcrypt>=4.0.0,<5.0.0 + +# System metrics +psutil>=6.0.0,<7.0.0 + +# Environment variable loading +python-dotenv>=1.0.0,<2.0.0 + +# ASGI server +uvicorn[standard]>=0.30.0,<1.0.0 \ No newline at end of file diff --git a/routes/auth.py b/routes/auth.py index 1dd3b39..c6f3796 100644 --- a/routes/auth.py +++ b/routes/auth.py @@ -2,18 +2,15 @@ from datetime import datetime from fastapi import APIRouter, Request, Depends, Form from fastapi.responses import HTMLResponse, RedirectResponse -from fastapi.templating import Jinja2Templates from sqlalchemy.orm import Session from core.database import get_db from core.models import AdminUser +from core.templates import templates from core.security import get_password_hash, verify_password, create_access_token from core.config import settings from core.rate_limiter import login_rate_limiter -templates = Jinja2Templates(directory="templates") -templates.env.globals['app_title'] = settings.APP_TITLE - router = APIRouter() diff --git a/routes/classrooms.py b/routes/classrooms.py new file mode 100644 index 0000000..d29081e --- /dev/null +++ b/routes/classrooms.py @@ -0,0 +1,498 @@ +"""Classrooms page routes - manages Classrooms and LXD Profiles""" +from fastapi import APIRouter, Request, Depends, HTTPException, status +from fastapi.responses import HTMLResponse, JSONResponse +from sqlalchemy.orm import Session + +from core.database import get_db +from core.models import AdminUser, LXDSettings, Classroom +from core.templates import templates +from core.config import settings +from services.lxd_service import LXDService + +router = APIRouter() + + +def get_current_user(request: Request, db: Session = Depends(get_db)): + """Get current logged-in user from session cookie""" + from jose import JWTError, jwt + from core.config import settings + + token = request.cookies.get("access_token") + if not token: + return None + + try: + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]) + username: str = payload.get("sub") + if username is None: + return None + user = db.query(AdminUser).filter(AdminUser.username == username).first() + return user + except JWTError: + return None + + +def require_auth(request: Request, db: Session = Depends(get_db)): + """Dependency to require authentication""" + user = get_current_user(request, db) + if user is None: + raise HTTPException( + status_code=status.HTTP_307_TEMPORARY_REDIRECT, + headers={"Location": "/login"} + ) + return user + + +@router.get("/classrooms", response_class=HTMLResponse) +async def classrooms_page( + request: Request, + user: AdminUser = Depends(require_auth), + db: Session = Depends(get_db) +): + """Classrooms management page""" + return templates.TemplateResponse("admin/classrooms.html", { + "request": request, + "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 +# ============================================================ + +@router.get("/api/classrooms") +async def get_classrooms(db: Session = Depends(get_db)): + """Return all classrooms.""" + try: + classrooms = db.query(Classroom).all() + return JSONResponse({ + "success": True, + "classrooms": [ + { + "id": c.id, + "name": c.name, + "username": c.username, + "image_type": c.image_type, + "lxd_profile": c.lxd_profile, + "image_fingerprint": c.image_fingerprint, + "image_alias": c.image_alias, + "image_description": c.image_description, + "ssh_config_template": c.ssh_config_template or "", + } + for c in classrooms + ] + }) + except Exception as exc: + import logging + logging.exception("Error fetching classrooms") + return JSONResponse({"success": False, "message": str(exc)}) + + +@router.get("/api/classrooms/{classroom_id}") +async def get_classroom(classroom_id: int, db: Session = Depends(get_db)): + """Return a single classroom by ID.""" + try: + classroom = db.query(Classroom).filter(Classroom.id == classroom_id).first() + if not classroom: + return JSONResponse({"success": False, "message": "Classroom not found"}, status_code=404) + return JSONResponse({ + "success": True, + "classroom": { + "id": classroom.id, + "name": classroom.name, + "username": classroom.username, + "image_type": classroom.image_type, + "lxd_profile": classroom.lxd_profile, + "image_fingerprint": classroom.image_fingerprint, + "image_alias": classroom.image_alias, + "image_description": classroom.image_description, + "ssh_config_template": classroom.ssh_config_template or "", + } + }) + except Exception as exc: + import logging + logging.exception("Error fetching classroom") + return JSONResponse({"success": False, "message": str(exc)}) + + +@router.post("/api/classrooms") +async def create_classroom(request: Request, db: Session = Depends(get_db)): + """Create a new classroom.""" + try: + data = await request.json() + name = (data.get("name") or "").strip() + if not name: + return JSONResponse({"success": False, "message": "Classroom name is required"}) + + username = (data.get("username") or "").strip() + if not username: + return JSONResponse({"success": False, "message": "Default username is required"}) + + # Check if name already exists + existing = db.query(Classroom).filter(Classroom.name == name).first() + if existing: + return JSONResponse({"success": False, "message": "Classroom name already exists"}) + + classroom = Classroom( + name=name, + username=username, + 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"), + image_description=data.get("image_description"), + ssh_config_template=data.get("ssh_config_template"), + ) + db.add(classroom) + db.commit() + db.refresh(classroom) + + return JSONResponse({ + "success": True, + "classroom": { + "id": classroom.id, + "name": classroom.name, + "username": classroom.username, + "image_type": classroom.image_type, + "lxd_profile": classroom.lxd_profile, + "image_fingerprint": classroom.image_fingerprint, + "image_alias": classroom.image_alias, + "image_description": classroom.image_description, + "ssh_config_template": classroom.ssh_config_template or "", + } + }) + except Exception as exc: + import logging + logging.exception("Error creating classroom") + return JSONResponse({"success": False, "message": str(exc)}) + + +@router.put("/api/classrooms/{classroom_id}") +async def update_classroom(classroom_id: int, request: Request, db: Session = Depends(get_db)): + """Update an existing classroom.""" + try: + classroom = db.query(Classroom).filter(Classroom.id == classroom_id).first() + if not classroom: + return JSONResponse({"success": False, "message": "Classroom not found"}, status_code=404) + + data = await request.json() + + # Check if name is being changed and if it already exists + new_name = data.get("name", classroom.name).strip() + if new_name != classroom.name: + existing = db.query(Classroom).filter(Classroom.name == new_name).first() + if existing: + return JSONResponse({"success": False, "message": "Classroom name already exists"}) + classroom.name = new_name + + new_username = (data.get("username") or "").strip() + if not new_username: + return JSONResponse({"success": False, "message": "Default username is required"}) + classroom.username = new_username + + classroom.image_type = data.get("image_type", classroom.image_type) + classroom.lxd_profile = data.get("lxd_profile") + classroom.image_fingerprint = data.get("image_fingerprint") + classroom.image_alias = data.get("image_alias") + classroom.image_description = data.get("image_description") + classroom.ssh_config_template = data.get("ssh_config_template") + + db.commit() + db.refresh(classroom) + + return JSONResponse({ + "success": True, + "classroom": { + "id": classroom.id, + "name": classroom.name, + "username": classroom.username, + "image_type": classroom.image_type, + "lxd_profile": classroom.lxd_profile, + "image_fingerprint": classroom.image_fingerprint, + "image_alias": classroom.image_alias, + "image_description": classroom.image_description, + "ssh_config_template": classroom.ssh_config_template or "", + } + }) + except Exception as exc: + import logging + logging.exception("Error updating classroom") + return JSONResponse({"success": False, "message": str(exc)}) + + +@router.delete("/api/classrooms/{classroom_id}") +async def delete_classroom(classroom_id: int, db: Session = Depends(get_db)): + """Delete a classroom.""" + try: + classroom = db.query(Classroom).filter(Classroom.id == classroom_id).first() + if not classroom: + return JSONResponse({"success": False, "message": "Classroom not found"}, status_code=404) + + db.delete(classroom) + db.commit() + + return JSONResponse({"success": True, "message": f"Classroom '{classroom.name}' deleted"}) + except Exception as exc: + import logging + logging.exception("Error deleting classroom") + return JSONResponse({"success": False, "message": str(exc)}) + + +# ============================================================ +# LXD Profile CRUD (JSON API) - moved from settings.py +# ============================================================ + +def _lxd_service_connected(db) -> LXDService | None: + """Return a connected LXDService or None.""" + svc = LXDService(db) + svc.get_client() + return svc if svc.is_connected() else None + + +def _parse_size_gib_shared(value: str): + """Convert LXD size string (4GiB, 4096MiB …) to integer GiB. Returns None if unparseable.""" + import re + if not value: + return None + m = re.match(r'^(\d+(?:\.\d+)?)\s*(GiB|GB|MiB|MB|KiB|KB)?$', value.strip(), re.IGNORECASE) + if not m: + return None + num, unit = float(m.group(1)), (m.group(2) or 'GiB').upper() + if unit in ('MIB', 'MB'): + return max(1, round(num / 1024)) + if unit in ('KIB', 'KB'): + return max(1, round(num / (1024 * 1024))) + return round(num) + + +def _profile_to_dict(profile) -> dict: + """Serialize a pylxd Profile object to a JSON-friendly dict.""" + cfg = profile.config or {} + devices = profile.devices or {} + root_dev = devices.get("root", {}) + cpu_raw = cfg.get("limits.cpu") + return { + "name": profile.name, + "description": profile.description or "", + "cpu": int(cpu_raw) if cpu_raw and cpu_raw.isdigit() else None, + "memory": _parse_size_gib_shared(cfg.get("limits.memory")), + "disk": _parse_size_gib_shared(root_dev.get("size")), + "cloud_init": cfg.get("user.user-data") or "", + } + + +@router.get("/api/lxd/profiles") +async def get_lxd_profiles(db: Session = Depends(get_db)): + """Return all LXD profiles with parsed resource defaults.""" + import re + 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"}) + + def _parse_size_gib(value: str) -> int | None: + """Convert LXD size string like '4GiB', '4096MiB', '2GB' to integer GiB.""" + if not value: + return None + value = value.strip() + m = re.match(r'^(\d+(?:\.\d+)?)\s*(GiB|GB|MiB|MB|KiB|KB)?$', value, re.IGNORECASE) + if not m: + return None + num, unit = float(m.group(1)), (m.group(2) or 'GiB').upper() + if unit in ('MIB', 'MB'): + return max(1, round(num / 1024)) + if unit in ('KIB', 'KB'): + return max(1, round(num / (1024 * 1024))) + return round(num) # GiB / GB + + try: + profiles = [] + for profile in lxd_service.client.profiles.all(): + cfg = profile.config or {} + devices = profile.devices or {} + + # Parse CPU + cpu_raw = cfg.get("limits.cpu") + cpu = int(cpu_raw) if cpu_raw and cpu_raw.isdigit() else None + + # Parse memory + memory = _parse_size_gib(cfg.get("limits.memory")) + + # Parse disk from root device + root_dev = devices.get("root", {}) + disk = _parse_size_gib(root_dev.get("size")) + + # Cloud-init template presence + has_cloud_init = bool(cfg.get("user.user-data")) + + profiles.append({ + "name": profile.name, + "description": profile.description or "", + "cpu": cpu, + "memory": memory, + "disk": disk, + "has_cloud_init": has_cloud_init, + }) + + return JSONResponse({"success": True, "profiles": profiles}) + except Exception: + import logging + logging.exception("Error fetching LXD profiles") + return JSONResponse({"success": False, "message": "Failed to fetch profiles"}) + + +@router.get("/api/lxd/profiles/{name}") +async def get_lxd_profile(name: str, db: Session = Depends(get_db)): + """Return full details of a single LXD profile.""" + svc = _lxd_service_connected(db) + if not svc: + return JSONResponse({"success": False, "message": "LXD not connected"}) + try: + profile = svc.client.profiles.get(name) + return JSONResponse({"success": True, "profile": _profile_to_dict(profile)}) + except Exception: + return JSONResponse({"success": False, "message": f"Profile '{name}' not found"}, status_code=404) + + +@router.post("/api/lxd/profiles") +async def create_lxd_profile(request: Request, db: Session = Depends(get_db)): + """Create a new LXD profile.""" + svc = _lxd_service_connected(db) + if not svc: + return JSONResponse({"success": False, "message": "LXD not connected"}) + try: + data = await request.json() + name = (data.get("name") or "").strip() + if not name: + return JSONResponse({"success": False, "message": "Profile name is required"}) + + config = {} + devices = {} + if data.get("cpu"): + config["limits.cpu"] = str(int(data["cpu"])) + if data.get("memory"): + config["limits.memory"] = f"{int(data['memory'])}GiB" + if data.get("cloud_init"): + config["user.user-data"] = data["cloud_init"] + if data.get("disk"): + devices["root"] = { + "type": "disk", + "path": "/", + "pool": "default", + "size": f"{int(data['disk'])}GiB", + } + + profile = svc.client.profiles.create( + name=name, + description=data.get("description", ""), + config=config, + devices=devices, + ) + return JSONResponse({"success": True, "profile": _profile_to_dict(profile)}) + except Exception as exc: + import logging + logging.exception("Error creating LXD profile") + return JSONResponse({"success": False, "message": str(exc)}) + + +@router.put("/api/lxd/profiles/{name}") +async def update_lxd_profile(name: str, request: Request, db: Session = Depends(get_db)): + """Update an existing LXD profile's resource limits and cloud-init.""" + svc = _lxd_service_connected(db) + if not svc: + return JSONResponse({"success": False, "message": "LXD not connected"}) + try: + profile = svc.client.profiles.get(name) + data = await request.json() + + cfg = dict(profile.config or {}) + devices = dict(profile.devices or {}) + + # Update limits + if data.get("cpu") is not None: + cfg["limits.cpu"] = str(int(data["cpu"])) + if data.get("memory") is not None: + cfg["limits.memory"] = f"{int(data['memory'])}GiB" + if "cloud_init" in data: + if data["cloud_init"]: + cfg["user.user-data"] = data["cloud_init"] + else: + cfg.pop("user.user-data", None) + + # Update root disk device + if data.get("disk") is not None: + root = dict(devices.get("root", { + "type": "disk", "path": "/", "pool": "default" + })) + root["size"] = f"{int(data['disk'])}GiB" + devices["root"] = root + + profile.config = cfg + profile.devices = devices + if "description" in data: + profile.description = data["description"] + profile.save() + + return JSONResponse({"success": True, "profile": _profile_to_dict(profile)}) + except Exception as exc: + import logging + logging.exception("Error updating LXD profile") + return JSONResponse({"success": False, "message": str(exc)}) + + +@router.delete("/api/lxd/profiles/{name}") +async def delete_lxd_profile(name: str, db: Session = Depends(get_db)): + """Delete an LXD profile (cannot delete profiles in use).""" + if name == "default": + return JSONResponse({"success": False, "message": "Cannot delete the 'default' profile"}) + + # Check if profile is used by any classroom + from core.models import Classroom + using_classrooms = db.query(Classroom).filter(Classroom.lxd_profile == name).all() + if using_classrooms: + classroom_names = ", ".join([c.name for c in using_classrooms]) + return JSONResponse({ + "success": False, + "message": f"Cannot delete profile '{name}': It is used by {len(using_classrooms)} classroom(s): {classroom_names}. Please update the classroom(s) to use a different profile first." + }) + + svc = _lxd_service_connected(db) + if not svc: + return JSONResponse({"success": False, "message": "LXD not connected"}) + try: + profile = svc.client.profiles.get(name) + profile.delete() + return JSONResponse({"success": True, "message": f"Profile '{name}' deleted"}) + except Exception as exc: + import logging + logging.exception("Error deleting LXD profile") + return JSONResponse({"success": False, "message": str(exc)}) diff --git a/routes/dashboard.py b/routes/dashboard.py index 047a89e..0a9710e 100644 --- a/routes/dashboard.py +++ b/routes/dashboard.py @@ -2,18 +2,15 @@ from datetime import datetime from fastapi import APIRouter, Request, Depends from fastapi.responses import HTMLResponse, JSONResponse -from fastapi.templating import Jinja2Templates from sqlalchemy.orm import Session from core.database import get_db -from core.models import AdminUser, LXDSettings, VMDefaultSettings +from core.models import AdminUser, LXDSettings, Classroom +from core.templates import templates from core.config import settings from services.metrics_service import get_system_metrics from services.lxd_service import LXDService -templates = Jinja2Templates(directory="templates") -templates.env.globals['app_title'] = settings.APP_TITLE - router = APIRouter() @@ -21,7 +18,7 @@ def get_current_user(request: Request, db: Session = Depends(get_db)): """Get current logged-in user from session cookie""" from jose import JWTError, jwt from core.config import settings - + token = request.cookies.get("access_token") if not token: return None @@ -93,6 +90,9 @@ async def dashboard( except Exception: lxd_connected = False + # Get default classroom (first one or None) + default_classroom = db.query(Classroom).first() + return templates.TemplateResponse("admin/dashboard.html", { "request": request, "username": user.username, @@ -104,7 +104,7 @@ async def dashboard( "instances": instances, "page": page, "search": search, - "vm_defaults": db.query(VMDefaultSettings).first(), + "default_classroom": default_classroom, # System metrics "cpu_percent": metrics["cpu_percent"], "memory_used": metrics["memory_used"], diff --git a/routes/instances.py b/routes/instances.py index 2bd728f..14dc306 100644 --- a/routes/instances.py +++ b/routes/instances.py @@ -7,7 +7,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, Classroom from core.validators import validate_instance_name, validate_positive_integer from services.lxd_service import LXDService from services.instance_tasks import InstanceTaskService, creation_tasks @@ -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) @@ -101,25 +101,27 @@ async def create_instance( "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 + # 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: - 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 + 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 - # 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 + # Note: lxd_profile is just the profile name (string), not a profile object + # Cloud-init is now handled by LXD when the profile is applied to the instance + cloud_init = None lxd_settings = { "use_socket": lxd_settings_db.use_socket, @@ -138,9 +140,9 @@ async def create_instance( instance_type=instance_type, lxd_settings=lxd_settings, cloud_init=cloud_init, - vm_swap=vm_swap, vm_username=vm_username, - image_fingerprint=image_fingerprint + image_fingerprint=image_fingerprint, + lxd_profile=lxd_profile ) return JSONResponse({ @@ -215,6 +217,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 +239,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) @@ -297,7 +303,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: @@ -329,21 +335,25 @@ async def bulk_create_instances( "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 + # 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: - 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 + vm_username = "ubuntu" + image_fingerprint = None + + cloud_init = None # Cloud-init is now handled via LXD profiles lxd_settings = { "use_socket": lxd_settings_db.use_socket, @@ -362,9 +372,9 @@ async 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 + image_fingerprint=image_fingerprint, + lxd_profile=lxd_profile ) return JSONResponse({ @@ -693,7 +703,7 @@ async def download_ssh_config( from services.ssh_key_service import get_instance_keys from services.ssh_config_service import create_ssh_config_files, DEFAULT_SSH_CONFIG_TEMPLATE from services.jump_user_service import create_jump_user - from core.models import ConnectionTemplate + from core.models import Classroom # Get SSH keys keys = get_instance_keys(instance_name) @@ -716,14 +726,12 @@ async def download_ssh_config( "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" + # Get classroom settings for username (used for VM login, not jump user) + classroom = db.query(Classroom).first() + username = classroom.username if classroom else "ubuntu" - # Get connection templates from DB - templates = db.query(ConnectionTemplate).first() - ssh_template = templates.ssh_config_template if templates and templates.ssh_config_template else DEFAULT_SSH_CONFIG_TEMPLATE + # Get SSH config template from classroom + ssh_template = classroom.ssh_config_template if classroom and classroom.ssh_config_template else DEFAULT_SSH_CONFIG_TEMPLATE # Get VM IP address from services.lxd_service import LXDService diff --git a/routes/settings.py b/routes/settings.py index 5c21006..da84966 100644 --- a/routes/settings.py +++ b/routes/settings.py @@ -1,18 +1,15 @@ -"""Settings routes: LXD, VM defaults, password change""" +"""Settings routes: LXD connection and password change""" from fastapi import APIRouter, Request, Depends, Form, HTTPException, status from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse -from fastapi.templating import Jinja2Templates from sqlalchemy.orm import Session from core.database import get_db -from core.models import AdminUser, LXDSettings, VMDefaultSettings, ContainerDefaultSettings +from core.models import AdminUser, LXDSettings +from core.templates import templates from core.config import settings from core.security import get_password_hash, verify_password from services.lxd_service import LXDService -templates = Jinja2Templates(directory="templates") -templates.env.globals['app_title'] = settings.APP_TITLE - router = APIRouter(tags=["settings"]) @@ -20,7 +17,7 @@ def get_current_user(request: Request, db: Session = Depends(get_db)): """Get current logged-in user from session cookie""" from jose import JWTError, jwt from core.config import settings - + token = request.cookies.get("access_token") if not token: return None @@ -55,37 +52,19 @@ async def settings_page( lxd_success: str = None, lxd_error: str = None, password_success: str = None, - password_error: str = None, - vm_success: str = None, - vm_error: str = None, - container_success: str = None, - container_error: str = None, - templates_success: str = None + password_error: str = None ): - """Settings page - change password, LXD configuration, and VM/container defaults""" - from core.models import ConnectionTemplate - + """Settings page - change password, LXD configuration""" 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", { "request": request, "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, "password_success": password_success, - "password_error": password_error, - "vm_success": vm_success, - "vm_error": vm_error, - "container_success": container_success, - "container_error": container_error, - "templates_success": templates_success + "password_error": password_error }) @@ -99,7 +78,6 @@ async def change_password( user: AdminUser = Depends(require_auth) ): """Handle password change""" - from core.models import ConnectionTemplate from core.security import validate_password_strength # Validate new password strength @@ -109,8 +87,6 @@ async def change_password( "request": request, "username": user.username, "lxd_settings": db.query(LXDSettings).first(), - "vm_settings": db.query(VMDefaultSettings).first(), - "connection_templates": db.query(ConnectionTemplate).first(), "password_error": error }) @@ -119,8 +95,6 @@ async def change_password( "request": request, "username": user.username, "lxd_settings": db.query(LXDSettings).first(), - "vm_settings": db.query(VMDefaultSettings).first(), - "connection_templates": db.query(ConnectionTemplate).first(), "password_error": "New passwords do not match" }) @@ -129,8 +103,6 @@ async def change_password( "request": request, "username": user.username, "lxd_settings": db.query(LXDSettings).first(), - "vm_settings": db.query(VMDefaultSettings).first(), - "connection_templates": db.query(ConnectionTemplate).first(), "password_error": "Current password is incorrect" }) @@ -201,153 +173,12 @@ async def generate_certificate(request: Request): return JSONResponse({"success": False, "message": "Failed to generate certificate"}) -@router.post("/settings/vm") -async def save_vm_settings( - request: Request, - username: str = Form(...), - cpu: int = Form(...), - memory: int = Form(...), - disk: int = Form(...), - swap: 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 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 - is_valid, error_msg = validate_cloud_init_template(cloud_init) - if not is_valid: - return RedirectResponse( - url=f"/settings?vm_error={error_msg}", - status_code=303 - ) - - settings = db.query(VMDefaultSettings).first() - - if settings: - settings.username = username - settings.cpu = cpu - settings.memory = memory - settings.disk = disk - settings.swap = swap - 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 = VMDefaultSettings( - username=username, - cpu=cpu, - memory=memory, - disk=disk, - swap=swap, - 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?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""" - from services.cloud_init_service import DEFAULT_CLOUD_INIT_TEMPLATE - return JSONResponse({ - "success": True, - "template": DEFAULT_CLOUD_INIT_TEMPLATE - }) - - @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""" - from services.lxd_service import LXDService - lxd_service = LXDService(db) lxd_service.get_client() @@ -388,7 +219,7 @@ async def get_available_images( images.append({ "fingerprint": img.fingerprint[:12], # Short fingerprint "full_fingerprint": img.fingerprint, - "description": f"{description}", + "description": f"{description} {[', '.join(aliases)] if aliases else ''}", "aliases": aliases, "architecture": img.architecture, "type": img.type, @@ -410,41 +241,3 @@ async def get_available_images( "success": False, "message": "Failed to fetch images" }) - - -@router.get("/settings/connection-templates") -async def get_connection_templates(db: Session = Depends(get_db)): - """Get connection templates (SSH config)""" - from core.models import ConnectionTemplate - from services.ssh_config_service import DEFAULT_SSH_CONFIG_TEMPLATE - - templates = db.query(ConnectionTemplate).first() - - return JSONResponse({ - "success": True, - "ssh_config_template": templates.ssh_config_template if templates and templates.ssh_config_template else DEFAULT_SSH_CONFIG_TEMPLATE - }) - - -@router.post("/settings/connection-templates") -async def save_connection_templates( - request: Request, - ssh_config_template: str = Form(...), - db: Session = Depends(get_db) -): - """Save connection templates""" - from core.models import ConnectionTemplate - - templates = db.query(ConnectionTemplate).first() - - if templates: - templates.ssh_config_template = ssh_config_template - else: - templates = ConnectionTemplate( - ssh_config_template=ssh_config_template - ) - db.add(templates) - - db.commit() - - return RedirectResponse(url="/settings?templates_success=Connection templates saved successfully", status_code=303) diff --git a/screenshots/dashboard.png b/screenshots/dashboard.png index 8b97254..8154c1a 100644 Binary files a/screenshots/dashboard.png and b/screenshots/dashboard.png differ diff --git a/services/bulk_service.py b/services/bulk_service.py index 726efba..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: @@ -208,13 +248,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 + 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 @@ -224,7 +264,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 """ @@ -262,9 +301,9 @@ 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 + image_fingerprint=image_fingerprint, + lxd_profile=lxd_profile ) # Wait for this instance to complete before starting next @@ -334,26 +373,26 @@ 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 + 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_username, image_fingerprint, lxd_profile) ) thread.daemon = True thread.start() - + return op_id @staticmethod diff --git a/services/cloud_init_service.py b/services/cloud_init_service.py index 25d18c2..e0b025f 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,51 +31,81 @@ """ -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, instance_type: str = "container") -> 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. - + custom_template: Custom template from LXD profile of the classroom. If None, uses default template. + public_key: SSH public key to use. If None, uses empty string. + username: Username for the instance. If None, uses 'ubuntu'. + instance_type: "virtual-machine" or "container" - determines default template if no custom_template + Returns: - Cloud-init template with username, public key, and swap size replaced + Cloud-init template with {username} and {public_key} placeholders replaced """ - template = custom_template if custom_template else DEFAULT_CLOUD_INIT_TEMPLATE - - # Use provided values or fallback to settings + # Select template: custom template takes precedence, otherwise use default based on instance type + if custom_template: + template = custom_template + elif instance_type == "virtual-machine": + template = DEFAULT_CLOUD_INIT_TEMPLATE_VM + else: + template = DEFAULT_CLOUD_INIT_TEMPLATE_CONTAINER + + # Use provided values or defaults ssh_public_key = public_key if public_key else '' vm_username = username if username else 'ubuntu' - + # 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 9037c6c..d13a487 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,9 +42,9 @@ 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 + 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 @@ -150,17 +149,29 @@ def create_instance_background( "limits.memory": f"{ram}GiB", } - # Add cloud-init user-data if provided (shared logic) - if cloud_init: - if ssh_keys and ssh_keys.get("public_key"): - instance_config["user.user-data"] = get_cloud_init_template( - cloud_init, - ssh_keys["public_key"], - vm_swap, - vm_username - ) - else: - instance_config["user.user-data"] = cloud_init + # Determine cloud-init template source and process placeholders + # 1. If LXD profile has cloud-init, use it + # 2. Otherwise, use default template based on instance type + # Then replace placeholders ({username}, {public_key}) with actual values + if ssh_keys and ssh_keys.get("public_key"): + from services.cloud_init_service import get_cloud_init_template + + # Get cloud-init from profile if available, otherwise None (uses default) + profile_cloud_init = None + if lxd_profile: + try: + profile = client.profiles.get(lxd_profile) + profile_cloud_init = profile.config.get("user.user-data") + except Exception: + pass # Profile not found yet, will be applied later + + # Process template (replaces {username} and {public_key} placeholders) + instance_config["user.user-data"] = get_cloud_init_template( + custom_template=profile_cloud_init, + public_key=ssh_keys["public_key"], + username=vm_username, + instance_type=instance_type + ) # Build devices (shared structure) instance_devices = { @@ -202,6 +213,36 @@ def create_instance_background( else: client.containers.create(config_data, wait=True) + # Apply LXD profile if specified (only non-resource settings) + # Note: user.user-data (cloud-init) was already processed during instance creation + 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) - from form + # - user.user-data - already processed with SSH key during creation + skip_keys = {"limits.cpu", "limits.memory", "user.user-data"} + 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() + creation_tasks[task_id]["message"] = f"Profile '{lxd_profile}' applied successfully" + 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) @@ -238,15 +279,15 @@ 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 + 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_username, image_fingerprint, lxd_profile) ) thread.daemon = True thread.start() 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/static/js/classrooms.js b/static/js/classrooms.js new file mode 100644 index 0000000..368b076 --- /dev/null +++ b/static/js/classrooms.js @@ -0,0 +1,625 @@ +// ============================================================ +// Classroom Management +// ============================================================ + +let _currentClassroomId = null; +let _classroomsLoaded = false; +let _lxdProfilesCache = null; + +async function loadClassroomsTab() { + _classroomsLoaded = true; + const loading = document.getElementById('classroom-list-loading'); + const list = document.getElementById('classroom-list'); + + loading.style.display = 'block'; + list.style.display = 'none'; + list.innerHTML = ''; + + // Load LXD profiles first + await loadLXDProfilesForClassroom(); + + try { + const res = await fetch('/api/classrooms'); + const data = await res.json(); + + loading.style.display = 'none'; + + if (!data.success) { + list.style.display = 'block'; + list.innerHTML = `
  • ${data.message}
  • `; + return; + } + + if (!data.classrooms.length) { + list.style.display = 'block'; + list.innerHTML = '
  • No classrooms found
  • '; + return; + } + + data.classrooms.forEach(c => { + const li = document.createElement('li'); + li.className = 'list-group-item list-group-item-action d-flex justify-content-between align-items-center'; + li.dataset.id = c.id; + li.style.cursor = 'pointer'; + + const typeIcon = c.image_type === 'virtual-machine' ? '🖥️' : '📦'; + const profileInfo = c.lxd_profile ? ` • Profile: ${c.lxd_profile}` : ''; + + li.innerHTML = ` +
    + ${c.name} +
    ${typeIcon} ${c.image_type === 'virtual-machine' ? 'VM' : 'Container'}${profileInfo} +
    + `; + li.addEventListener('click', () => selectClassroom(c.id)); + list.appendChild(li); + }); + + list.style.display = 'block'; + } catch (err) { + loading.style.display = 'none'; + list.style.display = 'block'; + list.innerHTML = `
  • ${err.message}
  • `; + } +} + +async function loadLXDProfilesForClassroom() { + // If already loaded, skip + if (_lxdProfilesCache) return; + + try { + const res = await fetch('/api/lxd/profiles'); + const data = await res.json(); + if (data.success) { + _lxdProfilesCache = data.profiles; + } + } catch (err) { + console.warn('Could not load LXD profiles:', err.message); + } +} + +function populateProfileDropdowns(prefix) { + const select = document.getElementById(`${prefix}-lxd-profile`); + if (!select) { + console.warn(`Dropdown ${prefix}-lxd-profile not found in DOM`); + return; + } + if (!_lxdProfilesCache) { + console.warn('LXD profiles cache is empty'); + return; + } + + // Keep the first option, remove the rest + while (select.options.length > 1) { + select.remove(1); + } + + _lxdProfilesCache.forEach(p => { + const opt = document.createElement('option'); + opt.value = p.name; + opt.textContent = p.name + (p.description ? ` - ${p.description}` : ''); + select.appendChild(opt); + }); +} + +async function loadClassroomImages(context = 'edit') { + const prefix = context === 'new' ? 'cn' : 'cc'; + const imageType = document.getElementById(`${prefix}-image-type`).value; + const select = document.getElementById(`${prefix}-image-select`); + + select.innerHTML = ''; + select.disabled = true; + + try { + const res = await fetch(`/settings/vm/images?instance_type=${imageType}`); + const data = await res.json(); + + if (data.success && data.images) { + data.images.forEach(img => { + const opt = document.createElement('option'); + opt.value = img.fingerprint; + opt.textContent = img.description; + opt.dataset.fullFingerprint = img.full_fingerprint; + opt.dataset.alias = img.aliases.join(', '); + select.appendChild(opt); + }); + select.disabled = false; + } + } catch (err) { + console.error('Failed to load images:', err); + } +} + +function onClassroomImageSelect(context = 'edit') { + const prefix = context === 'new' ? 'cn' : 'cc'; + const select = document.getElementById(`${prefix}-image-select`); + const descInput = document.getElementById(`${prefix}-image-description`); + const fingerprintInput = document.getElementById(`${prefix}-image-fingerprint`); + const aliasInput = document.getElementById(`${prefix}-image-alias`); + + const selected = select.options[select.selectedIndex]; + if (selected.value) { + descInput.value = selected.textContent; + fingerprintInput.value = selected.dataset.fullFingerprint; + aliasInput.value = selected.dataset.alias; + } else { + descInput.value = ''; + fingerprintInput.value = ''; + aliasInput.value = ''; + } +} + +async function selectClassroom(id) { + // Highlight in list + document.querySelectorAll('#classroom-list .list-group-item').forEach(li => { + li.classList.toggle('active', li.dataset.id == id); + }); + + _currentClassroomId = id; + hideNewClassroomForm(); + + // Show edit card, hide placeholder + document.getElementById('classroom-placeholder').style.display = 'none'; + document.getElementById('classroom-edit-card').style.display = 'block'; + document.getElementById('classroom-alert').style.display = 'none'; + + // Ensure profiles are loaded, then populate dropdown + await loadLXDProfilesForClassroom(); + populateProfileDropdowns('cc'); + + // Fetch classroom details + try { + const res = await fetch(`/api/classrooms/${id}`); + const data = await res.json(); + if (!data.success) { showClassroomAlert('danger', data.message); return; } + const c = data.classroom; + + document.getElementById('classroom-edit-name').textContent = c.name; + document.getElementById('cc-name').value = c.name; + document.getElementById('cc-username').value = c.username; + document.getElementById('cc-image-type').value = c.image_type; + document.getElementById('cc-lxd-profile').value = c.lxd_profile || ''; + document.getElementById('cc-image-fingerprint').value = c.image_fingerprint || ''; + document.getElementById('cc-image-alias').value = c.image_alias || ''; + document.getElementById('cc-image-description').value = c.image_description || ''; + document.getElementById('cc-ssh-config').value = c.ssh_config_template || ''; + + // Load images for the selected type + await loadClassroomImages('edit'); + } catch (err) { + showClassroomAlert('danger', `Failed to load classroom: ${err.message}`); + } +} + +async function saveClassroom() { + if (!_currentClassroomId) return; + + const payload = { + name: document.getElementById('cc-name').value.trim(), + username: document.getElementById('cc-username').value.trim(), + image_type: document.getElementById('cc-image-type').value, + lxd_profile: document.getElementById('cc-lxd-profile').value || null, + image_fingerprint: document.getElementById('cc-image-fingerprint').value || null, + image_alias: document.getElementById('cc-image-alias').value || null, + image_description: document.getElementById('cc-image-description').value || null, + ssh_config_template: document.getElementById('cc-ssh-config').value, + }; + + if (!payload.name) { + showClassroomAlert('danger', 'Classroom name is required.'); + return; + } + + if (!payload.username) { + showClassroomAlert('danger', 'Default username is required.'); + return; + } + + try { + const res = await fetch(`/api/classrooms/${_currentClassroomId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + const data = await res.json(); + if (data.success) { + showClassroomAlert('success', `Classroom '${payload.name}' saved.`); + document.getElementById('classroom-edit-name').textContent = payload.name; + loadClassroomsTab(); + } else { + showClassroomAlert('danger', data.message); + } + } catch (err) { + showClassroomAlert('danger', err.message); + } +} + +async function createClassroom() { + const name = document.getElementById('cn-name').value.trim(); + const username = document.getElementById('cn-username').value.trim(); + + if (!name) { showClassroomAlert('danger', 'Classroom name is required.'); return; } + if (!username) { showClassroomAlert('danger', 'Default username is required.'); return; } + + const payload = { + name, + username, + image_type: document.getElementById('cn-image-type').value, + lxd_profile: document.getElementById('cn-lxd-profile').value || null, + image_fingerprint: document.getElementById('cn-image-fingerprint').value || null, + image_alias: document.getElementById('cn-image-alias').value || null, + image_description: document.getElementById('cn-image-description').value || null, + ssh_config_template: document.getElementById('cn-ssh-config').value, + }; + + try { + const res = await fetch('/api/classrooms', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + const data = await res.json(); + if (data.success) { + showClassroomAlert('success', `Classroom '${name}' created.`); + hideNewClassroomForm(); + _classroomsLoaded = false; + loadClassroomsTab(); + } else { + showClassroomAlert('danger', data.message); + } + } catch (err) { + showClassroomAlert('danger', err.message); + } +} + +// Modal for classroom deletion +let classroomToDelete = null; + +function showDeleteClassroomModal() { + if (!_currentClassroomId) return; + + classroomToDelete = _currentClassroomId; + const classroomName = document.getElementById('classroom-edit-name').textContent; + document.getElementById('deleteClassroomName').textContent = classroomName; + + const modal = new bootstrap.Modal(document.getElementById('deleteClassroomModal')); + modal.show(); + + // Set up confirm button handler + document.getElementById('deleteClassroomConfirmBtn').onclick = async function() { + modal.hide(); + await performDeleteClassroom(); + }; +} + +async function performDeleteClassroom() { + if (!classroomToDelete) return; + + try { + const res = await fetch(`/api/classrooms/${classroomToDelete}`, { + method: 'DELETE' + }); + const data = await res.json(); + if (data.success) { + _currentClassroomId = null; + classroomToDelete = null; + document.getElementById('classroom-edit-card').style.display = 'none'; + document.getElementById('classroom-placeholder').style.display = 'block'; + showClassroomAlert('success', data.message); + _classroomsLoaded = false; + loadClassroomsTab(); + } else { + showClassroomAlert('danger', data.message); + } + } catch (err) { + showClassroomAlert('danger', err.message); + } +} + +async function deleteClassroom() { + showDeleteClassroomModal(); +} + +async function showNewClassroomForm() { + document.getElementById('classroom-new-card').style.display = 'block'; + document.getElementById('classroom-edit-card').style.display = 'none'; + document.getElementById('classroom-placeholder').style.display = 'none'; + document.getElementById('classroom-alert').style.display = 'none'; + + // Clear fields + ['cn-name', 'cn-username', 'cn-image-description', 'cn-image-fingerprint', 'cn-image-alias', 'cn-ssh-config'] + .forEach(id => { document.getElementById(id).value = ''; }); + document.getElementById('cn-lxd-profile').value = ''; + + // Ensure profiles are loaded, then populate dropdown + await loadLXDProfilesForClassroom(); + populateProfileDropdowns('cn'); + + // Deselect list item + document.querySelectorAll('#classroom-list .active').forEach(li => li.classList.remove('active')); + _currentClassroomId = null; + + // Load images + loadClassroomImages('new'); +} + +function hideNewClassroomForm() { + document.getElementById('classroom-new-card').style.display = 'none'; + if (!_currentClassroomId) { + document.getElementById('classroom-placeholder').style.display = 'block'; + } +} + +function showClassroomAlert(type, message) { + const el = document.getElementById('classroom-alert'); + el.style.display = 'block'; + el.innerHTML = `
    + ${message} + +
    `; +} + +async function loadDefaultSSHConfig(targetId) { + try { + const res = await fetch('/classrooms/connection-templates'); + const data = await res.json(); + if (data.success) { + document.getElementById(targetId).value = data.ssh_config_template; + } + } catch (err) { + console.error('Failed to load default SSH config:', err); + } +} + + +// ============================================================ +// LXD Profile Management +// ============================================================ + +let _currentProfileName = null; +let _profilesLoaded = false; + +async function loadProfilesTab() { + _profilesLoaded = true; + const loading = document.getElementById('profile-list-loading'); + const list = document.getElementById('profile-list'); + + loading.style.display = 'block'; + list.style.display = 'none'; + list.innerHTML = ''; + + try { + const res = await fetch('/api/lxd/profiles'); + const data = await res.json(); + + loading.style.display = 'none'; + + if (!data.success) { + list.style.display = 'block'; + list.innerHTML = `
  • ${data.message}
  • `; + return; + } + + if (!data.profiles.length) { + list.style.display = 'block'; + list.innerHTML = '
  • No profiles found
  • '; + return; + } + + data.profiles.forEach(p => { + const li = document.createElement('li'); + li.className = 'list-group-item list-group-item-action d-flex justify-content-between align-items-center'; + li.dataset.name = p.name; + li.style.cursor = 'pointer'; + + const specs = [ + p.cpu ? `${p.cpu}c` : null, + p.memory ? `${p.memory}G` : null, + p.disk ? `${p.disk}G` : null, + ].filter(Boolean).join(' / '); + + li.innerHTML = ` +
    + ${p.name} + ${p.description ? `
    ${p.description}` : ''} +
    + ${specs}${p.has_cloud_init ? ' ☁' : ''}`; + li.addEventListener('click', () => selectProfile(p.name)); + list.appendChild(li); + }); + + list.style.display = 'block'; + } catch (err) { + loading.style.display = 'none'; + list.style.display = 'block'; + list.innerHTML = `
  • ${err.message}
  • `; + } +} + +async function selectProfile(name) { + // Highlight in list + document.querySelectorAll('#profile-list .list-group-item').forEach(li => { + li.classList.toggle('active', li.dataset.name === name); + }); + + _currentProfileName = name; + hideNewProfileForm(); + + // Show edit card, hide placeholder + document.getElementById('profile-placeholder').style.display = 'none'; + document.getElementById('profile-edit-card').style.display = 'block'; + document.getElementById('profile-edit-name').textContent = name; + document.getElementById('profile-alert').style.display = 'none'; + + // Disable delete button for 'default' + const deleteBtn = document.getElementById('profile-delete-btn'); + const isDefault = (name === 'default'); + deleteBtn.disabled = isDefault; + deleteBtn.title = isDefault ? "Cannot delete the 'default' LXD profile" : "Delete this profile"; + deleteBtn.style.opacity = isDefault ? '0.5' : '1'; + deleteBtn.style.cursor = isDefault ? 'not-allowed' : 'pointer'; + + // Fetch full profile details (includes cloud-init text) + try { + const res = await fetch(`/api/lxd/profiles/${encodeURIComponent(name)}`); + const data = await res.json(); + if (!data.success) { showProfileAlert('danger', data.message); return; } + const p = data.profile; + document.getElementById('pe-description').value = p.description || ''; + document.getElementById('pe-cpu').value = p.cpu ?? ''; + document.getElementById('pe-memory').value = p.memory ?? ''; + document.getElementById('pe-disk').value = p.disk ?? ''; + document.getElementById('pe-cloud-init').value = p.cloud_init || ''; + } catch (err) { + showProfileAlert('danger', `Failed to load profile: ${err.message}`); + } +} + +async function saveProfile() { + if (!_currentProfileName) return; + const payload = { + description: document.getElementById('pe-description').value, + cpu: parseInt(document.getElementById('pe-cpu').value) || null, + memory: parseInt(document.getElementById('pe-memory').value) || null, + disk: parseInt(document.getElementById('pe-disk').value) || null, + cloud_init: document.getElementById('pe-cloud-init').value, + }; + + try { + const res = await fetch(`/api/lxd/profiles/${encodeURIComponent(_currentProfileName)}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + const data = await res.json(); + if (data.success) { + showProfileAlert('success', `Profile '${_currentProfileName}' saved.`); + loadProfilesTab(); + } else { + showProfileAlert('danger', data.message); + } + } catch (err) { + showProfileAlert('danger', err.message); + } +} + +async function createProfile() { + const name = document.getElementById('pn-name').value.trim(); + if (!name) { showProfileAlert('danger', 'Profile name is required.'); return; } + + const payload = { + name, + description: document.getElementById('pn-description').value, + cpu: parseInt(document.getElementById('pn-cpu').value) || null, + memory: parseInt(document.getElementById('pn-memory').value) || null, + disk: parseInt(document.getElementById('pn-disk').value) || null, + cloud_init: document.getElementById('pn-cloud-init').value, + }; + + try { + const res = await fetch('/api/lxd/profiles', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + const data = await res.json(); + if (data.success) { + showProfileAlert('success', `Profile '${name}' created.`); + hideNewProfileForm(); + _profilesLoaded = false; + loadProfilesTab(); + } else { + showProfileAlert('danger', data.message); + } + } catch (err) { + showProfileAlert('danger', err.message); + } +} + +// Modal for profile deletion +let profileToDelete = null; + +function showDeleteProfileModal() { + if (!_currentProfileName || _currentProfileName === 'default') return; + + profileToDelete = _currentProfileName; + document.getElementById('deleteProfileName').textContent = _currentProfileName; + + const modal = new bootstrap.Modal(document.getElementById('deleteProfileModal')); + modal.show(); + + // Set up confirm button handler + document.getElementById('deleteProfileConfirmBtn').onclick = async function() { + modal.hide(); + await performDeleteProfile(); + }; +} + +async function performDeleteProfile() { + if (!profileToDelete || profileToDelete === 'default') return; + + try { + const res = await fetch(`/api/lxd/profiles/${encodeURIComponent(profileToDelete)}`, { + method: 'DELETE' + }); + const data = await res.json(); + if (data.success) { + _currentProfileName = null; + profileToDelete = null; + document.getElementById('profile-edit-card').style.display = 'none'; + document.getElementById('profile-placeholder').style.display = 'block'; + showProfileAlert('success', data.message); + _profilesLoaded = false; + loadProfilesTab(); + } else { + showProfileAlert('danger', data.message); + } + } catch (err) { + showProfileAlert('danger', err.message); + } +} + +async function deleteProfile() { + showDeleteProfileModal(); +} + +function showNewProfileForm() { + document.getElementById('profile-new-card').style.display = 'block'; + document.getElementById('profile-edit-card').style.display = 'none'; + document.getElementById('profile-placeholder').style.display = 'none'; + document.getElementById('profile-alert').style.display = 'none'; + // Clear fields + ['pn-name', 'pn-description', 'pn-cpu', 'pn-memory', 'pn-disk', 'pn-cloud-init'] + .forEach(id => { document.getElementById(id).value = ''; }); + // Deselect list item + document.querySelectorAll('#profile-list .active').forEach(li => li.classList.remove('active')); + _currentProfileName = null; +} + +function hideNewProfileForm() { + document.getElementById('profile-new-card').style.display = 'none'; + if (!_currentProfileName) { + document.getElementById('profile-placeholder').style.display = 'block'; + } +} + +async function loadDefaultCloudInit(targetId, templateType = 'container') { + try { + 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; + } + } catch (err) { + console.error('Failed to load default cloud-init:', err); + } +} + +function showProfileAlert(type, message) { + const el = document.getElementById('profile-alert'); + el.style.display = 'block'; + el.innerHTML = `
    + ${message} + +
    `; +} diff --git a/static/js/dashboard.js b/static/js/dashboard.js index 9da2332..613246c 100644 --- a/static/js/dashboard.js +++ b/static/js/dashboard.js @@ -1,6 +1,96 @@ // Instance creation with progress tracking let createPollingInterval = null; +// ============== Classroom Picker ============== + +let _classroomCache = null; // { id -> { name, image_type, lxd_profile, ... } } + +async function loadClassrooms() { + try { + const res = await fetch('/api/classrooms'); + const data = await res.json(); + if (!data.success || !data.classrooms.length) return; + + _classroomCache = {}; + data.classrooms.forEach(c => { _classroomCache[c.id] = c; }); + + const selectors = [ + document.getElementById('instance_classroom'), + document.getElementById('bulk_classroom'), + ]; + + selectors.forEach(sel => { + if (!sel) return; + // Keep the placeholder option, clear the rest + while (sel.options.length > 1) sel.remove(1); + data.classrooms.forEach(c => { + const opt = document.createElement('option'); + 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 classrooms:', e.message); + } +} + +/** + * Apply selected classroom settings to form fields. + * @param {'instance'|'bulk'} context + */ +function applyClassroom(context) { + if (!_classroomCache) return; + + const prefix = context === 'bulk' ? 'bulk' : 'instance'; + const selEl = document.getElementById( + context === 'bulk' ? 'bulk_classroom' : 'instance_classroom' + ); + if (!selEl) return; + + const classroomId = selEl.value; + 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)}`) + .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 function initLiveSearch() { const searchInput = document.getElementById('search-instances'); @@ -73,6 +163,7 @@ function initLiveSearch() { // Initialize on page load document.addEventListener('DOMContentLoaded', function() { initLiveSearch(); + loadClassrooms(); const form = document.getElementById('create-instance-form'); if (form) { form.addEventListener('submit', async function(e) { @@ -84,13 +175,26 @@ 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; + const instanceType = form.instance_type.value; // From hidden field updated by applyClassroom + let lxdProfile = null; + + if (classroomId && _classroomCache && _classroomCache[classroomId]) { + const classroom = _classroomCache[classroomId]; + 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 @@ -358,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}`); @@ -383,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: