diff --git a/README.md b/README.md index 88e69d1..e4ce467 100644 --- a/README.md +++ b/README.md @@ -1 +1,341 @@ -# Depl0y \ No newline at end of file +# Depl0y + +**Automated VM Deployment Panel for Proxmox VE** + +Depl0y is a free, open-source web-based control panel that simplifies the deployment and management of virtual machines on Proxmox VE infrastructure. With an intuitive interface and powerful automation features, Depl0y makes VM provisioning accessible to everyone. + +![License](https://img.shields.io/badge/license-MIT-blue.svg) +![Python](https://img.shields.io/badge/python-3.11%2B-blue.svg) +![Vue.js](https://img.shields.io/badge/vue.js-3.x-green.svg) + +## Features + +### Core Functionality +- **Automated VM Deployment** - Deploy Ubuntu, Debian, CentOS, Rocky Linux, Alma Linux, and Windows VMs with a few clicks +- **⚡ Cloud Images** - Ultra-fast 30-second deployments using pre-configured OS images (Ubuntu, Debian) +- **Cloud-Init Integration** - Automatic configuration of Linux VMs with cloud-init +- **Multi-Hypervisor Support** - Manage multiple Proxmox VE hosts and clusters +- **Resource Management** - Real-time monitoring of CPU, memory, and disk usage across your infrastructure +- **ISO Management** - Upload, store, and manage OS installation images + +### Advanced Features +- **Update Management** - One-click system updates for deployed Linux VMs +- **QEMU Guest Agent** - Automatic installation and configuration +- **SSH Access** - Built-in SSH key management and password-based authentication +- **Network Configuration** - Static IP or DHCP configuration with cloud-init +- **Partition Management** - Single large partition or custom schemes for Linux deployments + +### Security & Access Control +- **Multi-User Support** - Role-based access control (Admin, Operator, Viewer) +- **2FA Authentication** - TOTP-based two-factor authentication +- **Encrypted Credentials** - All sensitive data is encrypted at rest +- **Audit Logging** - Track all user actions and system changes + +### User Experience +- **Modern UI** - Responsive, beautiful interface built with Vue.js +- **Real-time Status** - Live VM status updates and resource monitoring +- **Dashboard Analytics** - Overview of your entire virtualization infrastructure +- **RESTful API** - Complete API for automation and integration + +## Screenshots + +*Screenshots coming soon* + +## Quick Start + +### Prerequisites +- A server running Linux (Ubuntu 22.04+, Debian 11+, or similar) +- Docker and Docker Compose +- At least 2GB RAM and 20GB disk space +- Access to one or more Proxmox VE hosts + +### Installation + +1. **Clone the repository** + ```bash + git clone https://github.com/yourusername/depl0y.git + cd depl0y + ``` + +2. **Run the setup script** + ```bash + sudo ./scripts/setup.sh + ``` + +3. **Access the web interface** + - Open your browser and navigate to `http://your-server-ip` + - Log in with the credentials you created during setup + +4. **Add your first Proxmox host** + - Go to "Proxmox Hosts" in the sidebar + - Click "Add Host" and enter your Proxmox credentials + - Test the connection + +5. **Deploy your first VM** + - Go to "Virtual Machines" + - Click "Create VM" + - Fill in the required information and deploy! + +## Manual Installation + +If you prefer manual installation, see [docs/INSTALLATION.md](docs/INSTALLATION.md) for detailed instructions. + +## Configuration + +### Environment Variables + +Copy `.env.example` to `.env` and configure the following: + +```bash +# Database +MYSQL_ROOT_PASSWORD=your_secure_password +MYSQL_PASSWORD=your_depl0y_password + +# Security +SECRET_KEY=your_jwt_secret_key_minimum_32_chars +ENCRYPTION_KEY=your_fernet_encryption_key + +# Application +DEBUG=false +LOG_LEVEL=INFO +``` + +### Storage Locations + +By default, Depl0y stores data in: +- ISOs: `/var/lib/depl0y/isos` +- Cloud images: `/var/lib/depl0y/cloud-images` +- Cloud-init configs: `/var/lib/depl0y/cloud-init` +- SSH keys: `/var/lib/depl0y/ssh_keys` +- Logs: `/var/log/depl0y/` + +These can be customized in the `.env` file. + +## Architecture + +Depl0y consists of three main components: + +1. **Backend API** (FastAPI + Python) + - RESTful API + - Proxmox integration via proxmoxer + - Database management with SQLAlchemy + - Authentication and authorization + +2. **Frontend** (Vue.js 3) + - Single-page application + - Responsive design + - Real-time updates + +3. **Database** (MariaDB) + - Stores users, VMs, hosts, and configuration + - Automated backups recommended + +## API Documentation + +Once installed, API documentation is available at: +- Swagger UI: `http://your-server-ip/api/v1/docs` +- ReDoc: `http://your-server-ip/api/v1/redoc` + +## Usage + +### ⚡ Deploying with Cloud Images (Fastest - Recommended) + +Cloud images provide **30-second deployments** after initial setup: + +**One-Time Setup (1 minute):** +1. Go to **Settings** > **Cloud Image Setup** +2. Copy the setup command shown +3. SSH to your Depl0y server and run: `sudo /tmp/enable_cloud_images.sh` +4. Enter your Proxmox root password when prompted +5. Done! All cloud images are now enabled + +**Creating VMs:** +1. Navigate to "Virtual Machines" > "Create VM" +2. Select your Proxmox host and node +3. Choose **"Cloud Image (Fast)"** installation method +4. Select a cloud image (Ubuntu 24.04, Debian 12, etc.) +5. Configure resources (CPU, RAM, disk) +6. Set network configuration (DHCP or static IP) +7. Enter credentials for the VM +8. Click "Deploy" + +**First deployment:** ~5-10 minutes (creates template) +**All subsequent deployments:** ~30 seconds ⚡ + +The VM will be automatically created with: +- OS fully installed and configured +- Your credentials already set up +- Network configured +- SSH server enabled +- Ready to use immediately + +📖 **Full guide:** [CLOUD_IMAGES_GUIDE.md](CLOUD_IMAGES_GUIDE.md) + +### Deploying a Linux VM (Traditional ISO Method) + +1. Navigate to "Virtual Machines" > "Create VM" +2. Select your Proxmox host and node +3. Choose an OS type (Ubuntu, Debian, etc.) +4. Configure resources (CPU, RAM, disk) +5. Set network configuration (DHCP or static IP) +6. Enter credentials for the VM +7. Click "Deploy" + +The VM will be automatically created with: +- Cloud-init configuration +- QEMU guest agent installed +- SSH server enabled +- User account configured + +### Deploying a Windows VM + +1. First upload a Windows ISO to "ISO Images" +2. Create a new VM and select Windows OS type +3. Configure resources +4. Deploy and complete Windows installation manually via VNC + +### Managing Updates + +1. Select a VM from the list +2. Click "Check Updates" to see available updates +3. Click "Install Updates" to apply them +4. View update history in the VM details + +### Managing Proxmox Hosts + +1. Go to "Proxmox Hosts" +2. Add your Proxmox VE hosts with credentials +3. Poll hosts to refresh resource information +4. View nodes and their current utilization + +## Development + +### Backend Development + +```bash +cd backend +python3 -m venv venv +source venv/bin/activate +pip install -r requirements.txt +uvicorn app.main:app --reload +``` + +### Frontend Development + +```bash +cd frontend +npm install +npm run dev +``` + +### Running Tests + +```bash +# Backend tests +cd backend +pytest + +# Frontend tests +cd frontend +npm run test +``` + +### Deploying Changes + +After making changes to the code, deploy them to production: + +```bash +# Quick deploy with the deploy script +./deploy.sh + +# Or deploy manually - see DEPLOYMENT.md for details +``` + +📖 **Full deployment guide:** [DEPLOYMENT.md](DEPLOYMENT.md) + +## Contributing + +We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. + +1. Fork the repository +2. Create a feature branch (`git checkout -b feature/amazing-feature`) +3. Commit your changes (`git commit -m 'Add amazing feature'`) +4. Push to the branch (`git push origin feature/amazing-feature`) +5. Open a Pull Request + +## Security + +### Reporting Vulnerabilities + +Please report security vulnerabilities to security@example.com (not via public issues). + +### Best Practices + +- Change default passwords immediately after installation +- Use strong, unique passwords +- Enable 2FA for all admin accounts +- Keep Depl0y updated to the latest version +- Use HTTPS in production (configure SSL certificates) +- Regularly backup your database +- Restrict network access to trusted IPs if possible + +## Roadmap + +- [x] Cloud images for ultra-fast deployment +- [x] Template-based VM cloning +- [ ] Scheduled deployments +- [ ] VM snapshots management +- [ ] Backup automation +- [ ] Integration with monitoring tools (Prometheus, Grafana) +- [ ] Multi-language support +- [ ] Mobile app +- [ ] Ansible playbook execution +- [ ] Cost tracking and reporting +- [ ] API rate limiting + +## Troubleshooting + +### Common Issues + +**Cannot connect to Proxmox host** +- Verify credentials are correct +- Check network connectivity +- Ensure Proxmox API is accessible +- Verify SSL certificate settings + +**VMs not starting** +- Check Proxmox node has sufficient resources +- Verify ISO image is available +- Check VM logs in Proxmox + +**Database connection errors** +- Ensure MariaDB container is running +- Verify database credentials in `.env` +- Check database container logs + +For more help, see [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md) or open an issue. + +## License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. + +## Acknowledgments + +- Built with [FastAPI](https://fastapi.tiangolo.com/) +- Frontend powered by [Vue.js](https://vuejs.org/) +- Proxmox integration via [proxmoxer](https://github.com/proxmoxer/proxmoxer) +- Icons from various open-source projects + +## Support + +- **Documentation**: [docs/](docs/) +- **Issues**: [GitHub Issues](https://github.com/yourusername/depl0y/issues) +- **Discussions**: [GitHub Discussions](https://github.com/yourusername/depl0y/discussions) + +## Author + +Created with ❤️ by the Depl0y team and contributors. + +--- + +**Star this repo if you find it useful!** ⭐ diff --git a/docs/CLOUD_IMAGES_GUIDE.md b/docs/CLOUD_IMAGES_GUIDE.md new file mode 100644 index 0000000..da060ac --- /dev/null +++ b/docs/CLOUD_IMAGES_GUIDE.md @@ -0,0 +1,705 @@ +# Cloud Images - Complete Guide + +## Table of Contents +1. [What Are Cloud Images?](#what-are-cloud-images) +2. [Why Use Cloud Images?](#why-use-cloud-images) +3. [One-Time Setup](#one-time-setup) +4. [How to Use Cloud Images](#how-to-use-cloud-images) +5. [Available Cloud Images](#available-cloud-images) +6. [How It Works (Technical)](#how-it-works-technical) +7. [Troubleshooting](#troubleshooting) +8. [FAQ](#faq) + +--- + +## What Are Cloud Images? + +Cloud images are pre-configured, ready-to-deploy operating system disk images. Instead of manually installing an OS (which takes 15-20 minutes), cloud images allow you to: + +- **Deploy VMs in 30 seconds** (after initial template creation) +- **Automatically configure credentials** (username/password) +- **Auto-configure networking** (DHCP or static IP) +- **Skip manual OS installation** completely + +Think of it like cloning a pre-installed OS with your custom settings applied automatically. + +--- + +## Why Use Cloud Images? + +### Traditional ISO Installation: +- Upload ISO file (~1GB download) +- Create VM +- Boot from ISO +- Click through installation wizard +- Wait 15-20 minutes +- **Total time: 20-30 minutes per VM** + +### Cloud Image Installation: +- Select cloud image +- Configure CPU, RAM, disk, credentials +- Click "Create VM" +- **First time: 5-10 minutes** (creates reusable template) +- **Every time after: 30 seconds** ⚡ + +**100x faster after the first deployment!** + +--- + +## One-Time Setup + +Cloud images require SSH access to your Proxmox server for initial template creation. This is a **ONE-TIME** setup that takes about 1 minute. + +### Step 1: Check if Setup is Needed + +Open Depl0y web UI and go to **Settings** page. Look for the "Cloud Image Setup" section: + +- **Green box (✅)**: Setup already complete! You're good to go. +- **Yellow box (⚠️)**: Setup required. Continue to Step 2. + +### Step 2: Run the Setup Script + +The setup script is already on your Depl0y server at `/tmp/enable_cloud_images.sh`. + +**⚠️ IMPORTANT: Run this script ON YOUR DEPL0Y SERVER (not on Proxmox!)** + +The script is located on your Depl0y server at `/tmp/enable_cloud_images.sh`. It will automatically connect to Proxmox to set up SSH access. + +**Method 1: Copy from Web UI (Recommended)** + +1. In the Settings page, click the **"Copy"** button next to the setup command +2. SSH into your **Depl0y server** (the server where Depl0y is installed): + ```bash + ssh administrator@your-depl0y-server + # Or: ssh administrator@deploy + ``` +3. Paste and run the command: + ```bash + sudo /tmp/enable_cloud_images.sh + ``` + +**Method 2: Manual Command** + +If you're already logged into your Depl0y server, just run: +```bash +sudo /tmp/enable_cloud_images.sh +``` + +**What Happens:** +1. Script runs **on Depl0y server** (where you run the command) +2. Generates SSH keys **on Depl0y server** +3. Prompts you for **Proxmox root password** +4. Uses SSH to copy the key **from Depl0y to Proxmox** +5. Verifies connection works + +### Step 3: Enter Proxmox Password + +The script will prompt you: + +``` +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Please enter the ROOT PASSWORD for Proxmox server: pve.agit8or.net +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +Password: +``` + +**Enter your Proxmox root password** (the password will not be displayed as you type for security). + +### Step 4: Wait for Completion + +The script will: +1. ✓ Check if `sshpass` is installed (installs if needed) +2. ✓ Generate SSH key pair (if not exists) +3. ✓ Copy SSH public key to Proxmox +4. ✓ Verify SSH connection works + +You'll see: +``` +╔════════════════════════════════════════════════════════════╗ +║ ✅ SUCCESS! ║ +╚════════════════════════════════════════════════════════════╝ + +Cloud images are now fully configured! + +🎉 What happens now: + • Go to the web UI and create a VM + • Select any cloud image (Ubuntu, Debian, etc.) + • Click 'Create VM' + • The system will automatically: + - Download the cloud image to Proxmox (first time only) + - Create a bootable template (first time only) + - Clone and deploy your VM with your credentials + - Start the VM ready to use! + + First deployment per cloud image: ~5-10 minutes + All subsequent deployments: ~30 seconds + +✨ Everything is automatic from now on! +``` + +### Step 5: Verify in Web UI + +1. Go back to **Settings** page in Depl0y web UI +2. Click **"Re-check Status"** button +3. You should see a **green success box**: "Cloud Images Enabled! ✅" + +**Setup is complete!** You never have to do this again. + +--- + +## How to Use Cloud Images + +### Creating Your First Cloud Image VM + +1. **Go to Create VM Page** + - Click "Create VM" in the navigation menu + +2. **Basic Configuration** + - **Name**: Enter a name for your VM (e.g., "ubuntu-web-01") + - **Datacenter**: Select your Proxmox datacenter + - **Node**: Select which Proxmox node to deploy on + +3. **Installation Method** + - Select **"Cloud Image (Fast)"** + - Choose a cloud image from the dropdown: + - Ubuntu 24.04 LTS + - Ubuntu 22.04 LTS + - Ubuntu 20.04 LTS + - Debian 12 + - Debian 11 + +4. **Resources** + - **CPU Cores**: 2 (or more) + - **Memory (RAM)**: 2048 MB (or more) + - **Disk Size**: 20 GB (or more) + +5. **Storage & Network** + - **Storage Pool**: Select where to store the VM disk + - **Network Bridge**: Select network (usually vmbr0) + +6. **Cloud-Init Configuration** + - **Username**: Your desired username (e.g., "admin", "ubuntu") + - **Password**: Your desired password + - **SSH Public Key** (optional): Paste your SSH public key for key-based auth + - **IP Configuration**: Choose DHCP or Static IP + +7. **Advanced Options** (optional) + - CPU Type + - BIOS (SeaBIOS or UEFI) + - VGA Type + - Boot Order + +8. **Click "Create VM"** + +### What Happens Next? + +**First Time Using This Cloud Image:** +- Status: "Setting up cloud image (first time - takes ~5 min)..." +- The system automatically: + 1. Downloads the cloud image to Depl0y server (~300-700 MB) + 2. Uploads it to Proxmox via SSH + 3. Creates a VM template on Proxmox + 4. Clones the template to create your VM + 5. Configures cloud-init with your credentials + 6. Starts the VM + +**Time: 5-10 minutes** ⏱️ + +**Every Time After (Same Cloud Image):** +- Status: "Cloning template..." +- The system automatically: + 1. Clones existing template (instant!) + 2. Configures cloud-init with your credentials + 3. Starts the VM + +**Time: 30 seconds** ⚡ + +### Accessing Your VM + +Once the VM is created and started: + +**SSH Access:** +```bash +ssh username@vm-ip-address +``` + +**Console Access:** +- Click on the VM in Depl0y +- Click "Console" button +- Login with your configured username/password + +--- + +## Available Cloud Images + +### Ubuntu + +| Image | Version | Use Case | LTS Until | +|-------|---------|----------|-----------| +| Ubuntu 24.04 LTS | Noble Numbat | Latest features | April 2029 | +| Ubuntu 22.04 LTS | Jammy Jellyfish | Stable production | April 2027 | +| Ubuntu 20.04 LTS | Focal Fossa | Legacy apps | April 2025 | + +### Debian + +| Image | Version | Use Case | +|-------|---------|----------| +| Debian 12 | Bookworm | Latest stable | +| Debian 11 | Bullseye | Previous stable | + +**More cloud images can be added!** Contact your administrator or submit a feature request. + +--- + +## How It Works (Technical) + +### Architecture Overview + +``` +┌─────────────────┐ +│ Depl0y Server │ +│ │ +│ 1. Downloads │──────┐ +│ cloud image │ │ +└─────────────────┘ │ + │ SCP Upload + ▼ + ┌─────────────────┐ + │ Proxmox Server │ + │ │ + │ 2. Import disk │ + │ 3. Convert to │ + │ template │ + │ │ + │ Template ID: │ + │ 9001, 9002... │ + └─────────────────┘ + │ + │ Clone via API + ▼ + ┌─────────────────┐ + │ New VM │ + │ + Cloud-init │ + │ configured │ + └─────────────────┘ +``` + +### Template ID System + +Cloud images are converted to Proxmox templates with predictable IDs: + +- Template ID = `9000 + cloud_image_id` +- Ubuntu 24.04 (id=1) → Template 9001 +- Ubuntu 22.04 (id=2) → Template 9002 +- Debian 12 (id=3) → Template 9003 +- etc. + +### Template Creation Process + +**First VM deployment from a cloud image:** + +1. **Download** (Depl0y server): + ```bash + wget https://cloud-images.ubuntu.com/.../ubuntu-24.04-server-cloudimg-amd64.img + # Stored in: /var/lib/depl0y/cloud-images/ + ``` + +2. **Upload to Proxmox** (via SSH): + ```bash + scp ubuntu-24.04-server-cloudimg-amd64.img root@pve.agit8or.net:/tmp/ + ``` + +3. **Create Template VM** (via SSH on Proxmox): + ```bash + # Create empty VM + qm create 9001 --name Ubuntu-24.04-LTS --memory 2048 --cores 2 \ + --net0 virtio,bridge=vmbr0 --ostype l26 --scsihw virtio-scsi-pci + + # Import cloud image as disk + qm importdisk 9001 /tmp/ubuntu-24.04-server-cloudimg-amd64.img local-lvm --format qcow2 + + # Configure disk and boot + qm set 9001 --scsi0 local-lvm:vm-9001-disk-0 + qm set 9001 --boot order=scsi0 + + # Add cloud-init drive + qm set 9001 --ide2 local-lvm:cloudinit + + # Convert to template + qm template 9001 + ``` + +4. **Clone Template** (via Proxmox API): + ```python + proxmox.nodes(node).qemu(9001).clone.post( + newid=vm_id, + name=vm_name, + full=1, # Full clone + storage=storage + ) + ``` + +5. **Configure Cloud-init** (via Proxmox API): + ```python + proxmox.nodes(node).qemu(vm_id).config.set( + ciuser=username, + cipassword=password, + ipconfig0=network_config, + sshkeys=ssh_public_key + ) + ``` + +### Subsequent Deployments + +**All VMs after the first:** +- **Skip steps 1-3** (template already exists!) +- **Only run steps 4-5** (clone + configure) +- **Result**: 30-second deployments + +### Why SSH is Required + +The `qm importdisk` command does not have a Proxmox API endpoint. It can only be executed via SSH on the Proxmox server. This is why SSH setup is required for the **first** deployment of each cloud image type. + +**After template creation:** +- All operations use pure Proxmox API +- No SSH required for VM cloning +- Fast and efficient deployments + +### Security + +- **SSH Key Authentication**: Password-free after setup +- **Public Key Only**: Only public key stored on Proxmox +- **No Password Storage**: Proxmox password never stored in database +- **Template Isolation**: Templates separate from user VMs +- **Cloud-init Encryption**: Credentials encrypted in transit + +--- + +## Troubleshooting + +### Error: "SSH access not configured" + +**Symptoms:** +``` +Error: SSH access not configured. Please run this ONE-TIME setup command: + + sudo /tmp/enable_cloud_images.sh + +After that, cloud images will deploy automatically! +``` + +**Solution:** +1. SSH to your Depl0y server +2. Run: `sudo /tmp/enable_cloud_images.sh` +3. Enter your Proxmox root password when prompted +4. Wait for "✅ SUCCESS!" message +5. Try creating the VM again + +### SSH Setup Script Fails + +**Error: "Permission denied (publickey,password)"** + +**Possible Causes:** +- Wrong Proxmox password +- SSH password authentication disabled on Proxmox + +**Solution 1: Verify Password** +```bash +# Test SSH with password manually +ssh root@pve.agit8or.net +# If this fails with your password, reset Proxmox root password +``` + +**Solution 2: Enable Password Auth (if disabled)** + +On your Proxmox server, edit SSH config: +```bash +sudo nano /etc/ssh/sshd_config +``` + +Find and change: +``` +PasswordAuthentication yes +``` + +Restart SSH: +```bash +sudo systemctl restart sshd +``` + +Then run the setup script again. + +**Solution 3: Manual SSH Key Setup** + +If the automated script fails, set up SSH manually: + +1. On Depl0y server: + ```bash + sudo -u depl0y ssh-keygen -t rsa -b 4096 -f /opt/depl0y/.ssh/id_rsa -N "" + sudo -u depl0y cat /opt/depl0y/.ssh/id_rsa.pub + ``` + +2. Copy the public key output + +3. On Proxmox server: + ```bash + mkdir -p ~/.ssh + chmod 700 ~/.ssh + nano ~/.ssh/authorized_keys + # Paste the public key, save and exit + chmod 600 ~/.ssh/authorized_keys + ``` + +4. Test from Depl0y server: + ```bash + sudo -u depl0y ssh root@pve.agit8or.net "echo test" + ``` + +### VM Created But Won't Boot + +**Symptoms:** +- VM starts but no OS loads +- Black screen or BIOS errors +- "No bootable device" error + +**Cause:** +Template doesn't exist or is corrupt. + +**Solution:** +Delete the template and let it recreate: + +1. Check which template exists: + ```bash + ssh root@pve.agit8or.net "qm list | grep 900" + ``` + +2. Delete the problematic template: + ```bash + # For Ubuntu 24.04 (template 9001) + ssh root@pve.agit8or.net "qm destroy 9001" + ``` + +3. Try creating the VM again - template will be recreated automatically + +### Cloud-init Not Working + +**Symptoms:** +- Can't login with configured credentials +- Default username/password required +- Network not configured + +**Cause:** +Cloud-init configuration failed or not applied. + +**Solution:** + +1. Check cloud-init logs on the VM: + ```bash + # Login via console with default credentials + sudo cat /var/log/cloud-init.log + sudo cat /var/log/cloud-init-output.log + ``` + +2. Verify cloud-init config in Proxmox: + ```bash + ssh root@pve.agit8or.net "qm cloudinit dump user" + ``` + +3. Manually set cloud-init (if needed): + ```bash + # Via Proxmox UI: + # VM → Cloud-Init → Edit settings + ``` + +### Template Creation Stuck + +**Symptoms:** +- Status shows "Setting up cloud image..." for 20+ minutes +- No progress + +**Cause:** +- Network issue downloading cloud image +- Proxmox storage full +- SSH connection lost + +**Check Logs:** +```bash +sudo journalctl -u depl0y-backend -f --no-pager +``` + +**Solutions:** + +1. **Check download progress:** + ```bash + ls -lh /var/lib/depl0y/cloud-images/ + # If file is growing, download is in progress + ``` + +2. **Check Proxmox storage:** + ```bash + ssh root@pve.agit8or.net "df -h" + # Ensure storage has 5+ GB free + ``` + +3. **Check SSH connection:** + ```bash + sudo -u depl0y ssh root@pve.agit8or.net "echo test" + ``` + +4. **Restart the deployment:** + - Delete the partially created template + - Try creating the VM again + +### Check Templates on Proxmox + +List all templates: +```bash +ssh root@pve.agit8or.net "qm list | grep 900" +``` + +Example output: +``` +9001 Ubuntu-24.04-LTS 0 2048 0.00 0 +9002 Ubuntu-22.04-LTS 0 2048 0.00 0 +``` + +Delete a template: +```bash +ssh root@pve.agit8or.net "qm destroy 9001" +``` + +### Verify SSH Status from Command Line + +**Test SSH access:** +```bash +sudo -u depl0y ssh -o BatchMode=yes -o ConnectTimeout=5 \ + -o StrictHostKeyChecking=no root@pve.agit8or.net "echo test" +``` + +**If successful, you'll see:** +``` +test +``` + +**If not configured:** +``` +Permission denied (publickey,password). +``` + +--- + +## FAQ + +### Q: Do I need to run the setup script for every VM? +**A:** No! The setup script is run **once** per Depl0y installation. After that, all cloud image deployments are automatic. + +### Q: Do I need to run the setup script for each cloud image? +**A:** No! One setup enables **all** cloud images. The first deployment of each cloud image type takes 5-10 minutes to create the template, then all subsequent deployments are 30 seconds. + +### Q: What if I add a new Proxmox node? +**A:** The SSH key is configured per Proxmox host, not per node. If you add a node to an existing cluster, no new setup is needed. If you add a completely new Proxmox host/cluster, you'll need to run the setup script once for that new host. + +### Q: Can I use cloud images and ISO images? +**A:** Yes! Both installation methods work side-by-side. Use cloud images for quick deployments, ISOs for custom installations or distros without cloud images. + +### Q: Where are cloud images stored? +**A:** +- **Depl0y server**: `/var/lib/depl0y/cloud-images/` (original downloads) +- **Proxmox server**: Imported as disks in configured storage (e.g., local-lvm) + +### Q: How much disk space do cloud images use? +**A:** +- **Download**: 300-700 MB per cloud image +- **Template on Proxmox**: 2-3 GB per template +- **Cloned VMs**: Your configured disk size (20+ GB recommended) + +### Q: Can I customize the cloud images? +**A:** Cloud images are standard upstream images from Ubuntu/Debian. You can: +- Customize credentials (via cloud-init) +- Customize network (via cloud-init) +- Add SSH keys (via cloud-init) +- Install software after deployment (via Ansible, scripts, etc.) + +If you need pre-customized images, consider using Packer to build custom images. + +### Q: What happens if template creation fails? +**A:** The deployment will fail with an error message. Check the logs, fix the issue (usually SSH or storage), and try again. The system will automatically retry template creation. + +### Q: Can I delete templates? +**A:** Yes, but if you delete a template, the next VM deployment for that cloud image will take 5-10 minutes to recreate the template. Templates are safe to delete if you need to free space. + +### Q: How do I add more cloud images? +**A:** Cloud images are defined in the database. You can: +1. Contact your Depl0y administrator +2. Submit a feature request +3. Manually add via database (advanced users) + +### Q: Does cloud-init work on all images? +**A:** Yes! All official Ubuntu and Debian cloud images have cloud-init pre-installed and configured. That's what makes them "cloud images." + +### Q: Can I use Windows cloud images? +**A:** Windows doesn't have official cloud images like Linux. For Windows VMs, use the traditional ISO installation method. You can create custom Windows templates manually in Proxmox. + +### Q: What if I don't want to give Proxmox root SSH access? +**A:** SSH root access is required for the `qm importdisk` command during template creation. This is a Proxmox limitation. For security: +- SSH uses key-based auth (no password storage) +- SSH only used during template creation +- All subsequent operations use Proxmox API +- You can disable SSH after all templates are created (though you'll need to re-enable for new cloud image types) + +### Q: How do I know which template ID corresponds to which cloud image? +**A:** Template ID = 9000 + cloud_image_id. You can check cloud image IDs in the database or via the API. Common mappings: +- 9001: Ubuntu 24.04 LTS +- 9002: Ubuntu 22.04 LTS +- 9003: Ubuntu 20.04 LTS +- 9004: Debian 12 +- 9005: Debian 11 + +--- + +## Additional Resources + +### Check System Logs +```bash +# Backend logs +sudo journalctl -u depl0y-backend -f --no-pager + +# Last 50 lines +sudo journalctl -u depl0y-backend -n 50 --no-pager +``` + +### Test SSH Manually +```bash +# As depl0y user +sudo -u depl0y ssh root@pve.agit8or.net "qm list" + +# Check SSH key +sudo -u depl0y cat /opt/depl0y/.ssh/id_rsa.pub +``` + +### View Cloud Images in Database +```bash +sudo -u depl0y sqlite3 /var/lib/depl0y/db/depl0y.db "SELECT id, name, filename, is_downloaded FROM cloud_images WHERE is_available=1;" +``` + +### File Locations +- **Setup script**: `/tmp/enable_cloud_images.sh` +- **Cloud image downloads**: `/var/lib/depl0y/cloud-images/` +- **SSH keys**: `/opt/depl0y/.ssh/` +- **Backend code**: `/opt/depl0y/backend/app/services/deployment.py` +- **Database**: `/var/lib/depl0y/db/depl0y.db` + +--- + +## Support + +If you encounter issues not covered in this guide: + +1. **Check logs**: `sudo journalctl -u depl0y-backend -f` +2. **Verify SSH**: `sudo -u depl0y ssh root@pve.agit8or.net "echo test"` +3. **Check templates**: `ssh root@pve.agit8or.net "qm list | grep 900"` +4. **Contact support** or submit a bug report via the web UI + +--- + +**Happy deploying!** 🚀 diff --git a/docs/CLOUD_IMAGES_QUICKSTART.md b/docs/CLOUD_IMAGES_QUICKSTART.md new file mode 100644 index 0000000..869cd88 --- /dev/null +++ b/docs/CLOUD_IMAGES_QUICKSTART.md @@ -0,0 +1,139 @@ +# Cloud Images - Quick Start Guide + +## What Are Cloud Images? + +Cloud images let you deploy VMs in **30 seconds** instead of 20 minutes. No manual OS installation needed! + +--- + +## One-Time Setup (Takes 1 Minute) + +### Step 1: Check Status in Web UI + +Go to **Settings** → Look for **"Cloud Image Setup"** section + +- **Green box (✅)**: Already configured! Skip to "Using Cloud Images" below. +- **Yellow box (⚠️)**: Setup needed. Continue to Step 2. + +### Step 2: Run Setup Script + +**IMPORTANT:** Run this script **ON YOUR DEPL0Y SERVER** (not on Proxmox!) + +**Option A: Copy from Web UI** +1. Click the **"Copy"** button in Settings page +2. SSH to your **Depl0y server** (hostname: `deploy`) +3. Paste and run the command + +**Option B: Manual Command** + +SSH to your **Depl0y server** and run: + +```bash +sudo /tmp/enable_cloud_images.sh +``` + +**If you're already logged into the Depl0y server:** +Just run the command directly - no need to SSH anywhere! + +### Step 3: Enter Password + +When prompted, enter your **Proxmox root password**: + +``` +Password: [your-proxmox-root-password] +``` + +(Password won't be displayed as you type - this is normal) + +### Step 4: Done! + +You'll see: + +``` +✅ SUCCESS! + +Cloud images are now fully configured! +``` + +Go back to **Settings** in web UI and click **"Re-check Status"** to verify. + +--- + +## Using Cloud Images + +### Create a VM: + +1. Go to **"Create VM"** in web UI +2. Select **"Cloud Image (Fast)"** installation method +3. Choose a cloud image: + - Ubuntu 24.04 LTS + - Ubuntu 22.04 LTS + - Ubuntu 20.04 LTS + - Debian 12 + - Debian 11 +4. Configure CPU, RAM, disk size +5. Enter your desired **username** and **password** +6. Click **"Create VM"** + +### Deployment Times: + +- **First time using a cloud image**: 5-10 minutes (creates template) +- **Every time after that**: 30 seconds ⚡ + +--- + +## Troubleshooting + +### Error: "SSH access not configured" + +**Fix:** Run the setup script again: +```bash +sudo /tmp/enable_cloud_images.sh +``` + +### Setup script fails with "Permission denied" + +**Possible causes:** +- Wrong password +- SSH password auth disabled on Proxmox + +**Fix:** Try password again, or manually enable SSH password authentication on Proxmox. + +### VM boots but no OS + +**Cause:** Template is missing or corrupt + +**Fix:** Delete and recreate template: +```bash +ssh root@pve.agit8or.net "qm destroy 9001" +``` +Then create a new VM - template will be recreated. + +### Can't login to VM + +**Cause:** Cloud-init didn't configure credentials + +**Fix:** Check cloud-init logs via Proxmox console: +```bash +sudo cat /var/log/cloud-init.log +``` + +--- + +## Get Help + +- **Full documentation**: `CLOUD_IMAGES_GUIDE.md` +- **Check logs**: `sudo journalctl -u depl0y-backend -f` +- **Verify SSH**: `sudo -u depl0y ssh root@pve.agit8or.net "echo test"` +- **Check templates**: `ssh root@pve.agit8or.net "qm list | grep 900"` + +--- + +## Summary + +✅ **One-time setup**: 1 minute +✅ **Maintenance**: Zero +✅ **Deployment time**: 30 seconds +✅ **Manual installation**: Never again + +**Questions?** See full guide: `CLOUD_IMAGES_GUIDE.md` diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md new file mode 100644 index 0000000..38dcbc7 --- /dev/null +++ b/docs/CONTRIBUTING.md @@ -0,0 +1,132 @@ +# Contributing to Depl0y + +Thank you for your interest in contributing to Depl0y! We welcome contributions from everyone. + +## How Can I Contribute? + +### Reporting Bugs + +Before submitting a bug report: +- Check the [existing issues](https://github.com/yourusername/depl0y/issues) to avoid duplicates +- Gather information about the bug +- Try to reproduce it with the latest version + +When submitting a bug report, include: +- Clear title and description +- Steps to reproduce +- Expected vs actual behavior +- Screenshots if applicable +- Environment details (OS, Docker version, etc.) +- Relevant logs + +### Suggesting Enhancements + +Enhancement suggestions are tracked as GitHub issues. When creating an enhancement suggestion: +- Use a clear, descriptive title +- Provide detailed description of the proposed feature +- Explain why this enhancement would be useful +- List any alternative solutions you've considered + +### Pull Requests + +1. **Fork the repository** +2. **Create a branch** from `main`: + ```bash + git checkout -b feature/my-feature + ``` +3. **Make your changes** +4. **Test thoroughly** +5. **Commit with clear messages**: + ```bash + git commit -m "Add feature: description" + ``` +6. **Push to your fork**: + ```bash + git push origin feature/my-feature + ``` +7. **Open a Pull Request** + +## Development Setup + +### Backend + +```bash +cd backend +python3 -m venv venv +source venv/bin/activate +pip install -r requirements.txt +uvicorn app.main:app --reload +``` + +### Frontend + +```bash +cd frontend +npm install +npm run dev +``` + +### Database + +```bash +docker-compose up -d db +``` + +## Code Style + +### Python + +- Follow PEP 8 +- Use type hints +- Write docstrings for functions and classes +- Maximum line length: 100 characters + +### JavaScript/Vue + +- Use ES6+ features +- Follow Vue.js style guide +- Use meaningful variable names +- Add comments for complex logic + +### Commits + +- Use present tense ("Add feature" not "Added feature") +- Reference issues and PRs when applicable +- Keep commits focused and atomic + +## Testing + +### Backend Tests + +```bash +cd backend +pytest +``` + +### Frontend Tests + +```bash +cd frontend +npm run test +``` + +## Documentation + +- Update README.md if needed +- Add docstrings to new functions +- Update user guide for new features +- Include inline comments for complex code + +## Community + +- Be respectful and inclusive +- Help others learn and grow +- Follow the [Code of Conduct](CODE_OF_CONDUCT.md) + +## Questions? + +Feel free to open an issue or discussion if you have questions! + +## License + +By contributing, you agree that your contributions will be licensed under the MIT License. diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md new file mode 100644 index 0000000..77d8b25 --- /dev/null +++ b/docs/DEPLOYMENT.md @@ -0,0 +1,571 @@ +# Depl0y Deployment Guide + +This guide covers how to deploy Depl0y updates, make code changes, and manage the production environment. + +## Table of Contents +- [Development Workflow](#development-workflow) +- [Making Code Changes](#making-code-changes) +- [Deploying Changes](#deploying-changes) +- [Creating a Release](#creating-a-release) +- [Update Distribution](#update-distribution) +- [Rollback Procedures](#rollback-procedures) + +--- + +## Development Workflow + +### Project Structure +``` +/home/administrator/depl0y/ # Development directory +├── backend/ # Python FastAPI backend +│ ├── app/ +│ │ ├── api/ # API endpoints +│ │ ├── core/ # Core functionality +│ │ ├── models/ # Database models +│ │ └── services/ # Business logic +│ ├── requirements.txt +│ └── main.py +├── frontend/ # Vue.js frontend +│ ├── src/ +│ │ ├── views/ # Page components +│ │ ├── services/ # API services +│ │ ├── store/ # State management +│ │ └── router/ # Routes +│ ├── package.json +│ └── vite.config.js +├── scripts/ # Deployment scripts +├── install.sh # One-line installer +└── *.md # Documentation + +/opt/depl0y/ # Production directory +├── backend/ # Production backend +├── frontend/dist/ # Built frontend assets +└── ... + +/var/lib/depl0y/ # Data directory +├── db/ # SQLite database +├── isos/ # ISO images +├── cloud-images/ # Cloud image cache +└── ssh_keys/ # SSH keys +``` + +--- + +## Making Code Changes + +### Backend Changes + +1. **Edit Backend Code** + ```bash + cd /home/administrator/depl0y/backend + # Edit files in app/ directory + ``` + +2. **Test Changes Locally (Optional)** + ```bash + cd /home/administrator/depl0y/backend + source venv/bin/activate + uvicorn app.main:app --reload --host 127.0.0.1 --port 8001 + ``` + +3. **Check Logs** + ```bash + sudo journalctl -u depl0y-backend -f + ``` + +### Frontend Changes + +1. **Edit Frontend Code** + ```bash + cd /home/administrator/depl0y/frontend + # Edit files in src/ directory + ``` + +2. **Test Changes Locally (Optional)** + ```bash + cd /home/administrator/depl0y/frontend + npm run dev + # Access at http://localhost:5173 + ``` + +3. **Check Console** + - Open browser Developer Tools (F12) + - Check Console for errors + - Check Network tab for API calls + +--- + +## Deploying Changes + +### Quick Deploy Script + +Create a deployment script for convenience: + +```bash +cat > /home/administrator/depl0y/deploy.sh << 'EOF' +#!/bin/bash +set -e + +echo "🚀 Deploying Depl0y..." + +# Build frontend +echo "📦 Building frontend..." +cd /home/administrator/depl0y/frontend +npm run build + +# Deploy frontend +echo "📤 Deploying frontend..." +sudo rm -rf /opt/depl0y/frontend/dist/* +sudo cp -r dist/* /opt/depl0y/frontend/dist/ +sudo chown -R www-data:www-data /opt/depl0y/frontend/dist +sudo chmod -R 755 /opt/depl0y/frontend/dist + +# Deploy backend +echo "📤 Deploying backend..." +sudo cp -r /home/administrator/depl0y/backend/* /opt/depl0y/backend/ +sudo chown -R depl0y:depl0y /opt/depl0y/backend +sudo chmod -R 755 /opt/depl0y/backend + +# Restart services +echo "🔄 Restarting services..." +sudo systemctl restart depl0y-backend +sudo systemctl reload nginx + +# Check status +echo "✅ Checking status..." +sleep 2 +sudo systemctl status depl0y-backend --no-pager | head -15 + +echo "" +echo "✅ Deployment complete!" +echo "Check logs: sudo journalctl -u depl0y-backend -f" +EOF + +chmod +x /home/administrator/depl0y/deploy.sh +``` + +### Deploy with Script + +```bash +/home/administrator/depl0y/deploy.sh +``` + +### Manual Deployment Steps + +#### Deploy Backend Only + +```bash +# Copy backend files to production +sudo cp -r /home/administrator/depl0y/backend/* /opt/depl0y/backend/ + +# Fix permissions +sudo chown -R depl0y:depl0y /opt/depl0y/backend +sudo chmod -R 755 /opt/depl0y/backend + +# Restart backend service +sudo systemctl restart depl0y-backend + +# Check status +sudo systemctl status depl0y-backend +sudo journalctl -u depl0y-backend -n 50 +``` + +#### Deploy Frontend Only + +```bash +# Build frontend +cd /home/administrator/depl0y/frontend +npm run build + +# Deploy to production +sudo rm -rf /opt/depl0y/frontend/dist/* +sudo cp -r dist/* /opt/depl0y/frontend/dist/ + +# Fix permissions +sudo chown -R www-data:www-data /opt/depl0y/frontend/dist +sudo chmod -R 755 /opt/depl0y/frontend/dist + +# Reload nginx (optional, usually not needed) +sudo systemctl reload nginx +``` + +#### Deploy Both (Full Deploy) + +```bash +# Build frontend +cd /home/administrator/depl0y/frontend +npm run build + +# Deploy frontend +sudo rm -rf /opt/depl0y/frontend/dist/* +sudo cp -r dist/* /opt/depl0y/frontend/dist/ +sudo chown -R www-data:www-data /opt/depl0y/frontend/dist +sudo chmod -R 755 /opt/depl0y/frontend/dist + +# Deploy backend +sudo cp -r /home/administrator/depl0y/backend/* /opt/depl0y/backend/ +sudo chown -R depl0y:depl0y /opt/depl0y/backend +sudo chmod -R 755 /opt/depl0y/backend + +# Restart services +sudo systemctl restart depl0y-backend +sudo systemctl reload nginx + +# Verify +sudo systemctl status depl0y-backend +``` + +--- + +## Creating a Release + +### 1. Update Version Number + +Edit the version in the backend config: + +```bash +nano /home/administrator/depl0y/backend/app/core/config.py +``` + +Change: +```python +APP_VERSION: str = "1.1.0" # Update this +``` + +### 2. Update Release Notes + +Edit the system updates endpoint: + +```bash +nano /home/administrator/depl0y/backend/app/api/system_updates.py +``` + +Update the `release_notes` in the `/version` endpoint: +```python +"release_notes": f""" +Depl0y {settings.APP_VERSION} Release Notes: + +✨ New Features: +- Feature 1 +- Feature 2 + +🔧 Improvements: +- Improvement 1 +- Improvement 2 + +🐛 Bug Fixes: +- Fix 1 +- Fix 2 +""" +``` + +### 3. Deploy to Production + +```bash +/home/administrator/depl0y/deploy.sh +``` + +### 4. Test Update Endpoint + +```bash +curl http://localhost/api/v1/system-updates/version +``` + +Verify the version and release notes are correct. + +--- + +## Update Distribution + +Depl0y uses a **pull-based update system** where client instances pull updates from the main server (`deploy.agit8or.net`). + +### How Updates Work + +1. **Main Server** (deploy.agit8or.net) + - Serves version information via `/api/v1/system-updates/version` + - Provides update packages via `/api/v1/system-updates/download` + - Hosts the installer via `/install.sh` + +2. **Client Instances** + - Check for updates by querying main server + - Compare local version with latest version + - Download and apply updates if available + +### Making Main Server the Update Source + +If this is your main update server (`deploy.agit8or.net`): + +1. **Ensure the installer is accessible** + ```bash + # The installer should be served by nginx + curl http://deploy.agit8or.net/install.sh + ``` + +2. **Update the backend config if needed** + ```bash + nano /home/administrator/depl0y/backend/app/api/system_updates.py + ``` + + Verify: + ```python + UPDATE_SERVER = "http://deploy.agit8or.net" + ``` + +3. **Test the update endpoints** + ```bash + # Version info + curl http://deploy.agit8or.net/api/v1/system-updates/version + + # Download package (requires auth) + curl -H "Authorization: Bearer YOUR_TOKEN" \ + http://deploy.agit8or.net/api/v1/system-updates/download \ + -o test-package.tar.gz + ``` + +### Client Update Process + +When a client checks for updates (Settings → System Updates): + +1. Client calls `/api/v1/system-updates/check` +2. Backend queries main server at `http://deploy.agit8or.net/api/v1/system-updates/version` +3. Compares versions +4. If update available, shows "Install Update" button +5. When clicked, downloads from `http://deploy.agit8or.net/api/v1/system-updates/download` +6. Extracts, builds, deploys, and restarts + +--- + +## Rollback Procedures + +### Automatic Backups + +The update system creates automatic backups at: +``` +/opt/depl0y-backups/backup-YYYYMMDD-HHMMSS/ +``` + +### Manual Rollback + +1. **Stop the service** + ```bash + sudo systemctl stop depl0y-backend + ``` + +2. **Restore from backup** + ```bash + # Find latest backup + ls -la /opt/depl0y-backups/ + + # Restore backend + sudo cp -r /opt/depl0y-backups/backup-YYYYMMDD-HHMMSS/backend/* /opt/depl0y/backend/ + + # Restore frontend (if backed up) + sudo cp -r /opt/depl0y-backups/backup-YYYYMMDD-HHMMSS/frontend/* /opt/depl0y/frontend/ + ``` + +3. **Fix permissions** + ```bash + sudo chown -R depl0y:depl0y /opt/depl0y/backend + sudo chown -R www-data:www-data /opt/depl0y/frontend/dist + ``` + +4. **Restart service** + ```bash + sudo systemctl start depl0y-backend + sudo systemctl status depl0y-backend + ``` + +### Manual Backup Before Changes + +```bash +# Create backup directory +BACKUP_DIR="/opt/depl0y-backups/manual-$(date +%Y%m%d-%H%M%S)" +sudo mkdir -p "$BACKUP_DIR" + +# Backup backend +sudo cp -r /opt/depl0y/backend "$BACKUP_DIR/" + +# Backup frontend +sudo cp -r /opt/depl0y/frontend "$BACKUP_DIR/" + +# Backup database +sudo cp -r /var/lib/depl0y/db "$BACKUP_DIR/" + +echo "Backup created at $BACKUP_DIR" +``` + +--- + +## Common Deployment Issues + +### Issue: Backend Service Won't Start + +**Check logs:** +```bash +sudo journalctl -u depl0y-backend -n 100 +``` + +**Common causes:** +- Python syntax errors +- Missing dependencies +- Database connection issues +- Port already in use + +**Fix:** +```bash +# Check if port 8000 is in use +sudo lsof -i :8000 + +# Reinstall dependencies if needed +cd /opt/depl0y/backend +sudo -u depl0y venv/bin/pip install -r requirements.txt +``` + +### Issue: Frontend Shows Blank Page + +**Check:** +1. Browser console (F12) for JavaScript errors +2. Nginx error logs: `sudo tail -f /var/log/nginx/depl0y_error.log` +3. Verify build was successful +4. Check file permissions + +**Fix:** +```bash +# Rebuild and redeploy +cd /home/administrator/depl0y/frontend +npm run build +sudo rm -rf /opt/depl0y/frontend/dist/* +sudo cp -r dist/* /opt/depl0y/frontend/dist/ +sudo chown -R www-data:www-data /opt/depl0y/frontend/dist +sudo chmod -R 755 /opt/depl0y/frontend/dist +``` + +### Issue: API Calls Failing (500 Errors) + +**Check backend logs:** +```bash +sudo journalctl -u depl0y-backend -f +``` + +**Common causes:** +- Backend crashed +- Database errors +- API endpoint errors + +**Quick restart:** +```bash +sudo systemctl restart depl0y-backend +``` + +### Issue: Changes Not Appearing + +**For backend:** +```bash +# Make sure you restarted the service +sudo systemctl restart depl0y-backend + +# Verify files were copied +ls -la /opt/depl0y/backend/app/api/ +``` + +**For frontend:** +```bash +# Make sure you rebuilt +cd /home/administrator/depl0y/frontend +npm run build + +# Verify files were copied +ls -la /opt/depl0y/frontend/dist/ + +# Hard refresh browser (Ctrl+Shift+R or Cmd+Shift+R) +``` + +--- + +## Monitoring and Maintenance + +### Check Service Status +```bash +sudo systemctl status depl0y-backend +sudo systemctl status nginx +``` + +### View Logs +```bash +# Live backend logs +sudo journalctl -u depl0y-backend -f + +# Last 100 lines +sudo journalctl -u depl0y-backend -n 100 + +# Nginx access logs +sudo tail -f /var/log/nginx/depl0y_access.log + +# Nginx error logs +sudo tail -f /var/log/nginx/depl0y_error.log +``` + +### Restart Services +```bash +# Backend only +sudo systemctl restart depl0y-backend + +# Nginx only +sudo systemctl reload nginx + +# Both +sudo systemctl restart depl0y-backend +sudo systemctl reload nginx +``` + +### Check Disk Space +```bash +df -h /opt/depl0y +df -h /var/lib/depl0y +``` + +### Clean Up Old Backups +```bash +# List backups +ls -lah /opt/depl0y-backups/ + +# Remove old backups (keep last 5) +cd /opt/depl0y-backups/ +ls -t | tail -n +6 | xargs sudo rm -rf +``` + +--- + +## Quick Reference + +### Deploy Everything +```bash +cd /home/administrator/depl0y/frontend && npm run build && \ +sudo rm -rf /opt/depl0y/frontend/dist/* && \ +sudo cp -r dist/* /opt/depl0y/frontend/dist/ && \ +sudo cp -r /home/administrator/depl0y/backend/* /opt/depl0y/backend/ && \ +sudo chown -R www-data:www-data /opt/depl0y/frontend/dist && \ +sudo chown -R depl0y:depl0y /opt/depl0y/backend && \ +sudo systemctl restart depl0y-backend +``` + +### Check Everything +```bash +sudo systemctl status depl0y-backend nginx && \ +curl -s http://localhost/api/v1/system-updates/version | head -5 && \ +sudo journalctl -u depl0y-backend -n 10 +``` + +### Emergency Restart +```bash +sudo systemctl restart depl0y-backend nginx && \ +sleep 2 && \ +sudo systemctl status depl0y-backend +``` + +--- + +**For more information, see:** +- [INSTALL.md](INSTALL.md) - Installation guide +- [README.md](README.md) - Project overview +- [CLOUD_IMAGES_GUIDE.md](CLOUD_IMAGES_GUIDE.md) - Cloud images setup diff --git a/docs/INSTALL.md b/docs/INSTALL.md new file mode 100644 index 0000000..fadae15 --- /dev/null +++ b/docs/INSTALL.md @@ -0,0 +1,163 @@ +# Depl0y Installation Guide + +## One-Line Installation + +Install Depl0y with a single command: + +```bash +curl -fsSL http://deploy.agit8or.net/install.sh | sudo bash +``` + +That's it! The installer will: +- Install all dependencies (Python, Node.js, nginx, etc.) +- Create the depl0y system user +- Download and install the latest version from deploy.agit8or.net +- Configure the backend service +- Configure nginx as reverse proxy +- Set up proper permissions + +## After Installation + +1. **Access the Web Interface** + ``` + http://YOUR_SERVER_IP + ``` + +2. **Default Credentials** + ``` + Username: admin + Password: admin + ``` + + ⚠️ **IMPORTANT:** Change the default password immediately after first login! + +3. **Add Your Proxmox Host** + - Go to Settings → Proxmox Hosts + - Add your Proxmox datacenter details + - Generate API token in Proxmox and add it + +4. **Enable Cloud Images** (Optional but Recommended) + - Go to Settings → Cloud Images + - Click "🚀 Enable Cloud Images Now" + - Enter your Proxmox root password + - Wait ~30 seconds for automated setup + +5. **Configure Inter-Node SSH** (For Multi-Node Clusters Only) + - Go to Settings → Proxmox Cluster Inter-Node SSH + - Click "🔐 Enable Inter-Node SSH" + - Enter your Proxmox root password + - Wait ~30 seconds for automated setup + +## Manual Installation + +If you prefer to install manually, see [README.md](README.md) for detailed instructions. + +## Deployment Guide + +For developers and operations teams who need to deploy code changes or manage updates: + +📖 **See [DEPLOYMENT.md](DEPLOYMENT.md)** for complete deployment procedures including: +- Making and deploying code changes +- Creating releases and updates +- Rollback procedures +- Troubleshooting deployment issues + +## Requirements + +- Ubuntu 20.04+ or Debian 11+ (recommended) +- 2GB RAM minimum +- 20GB disk space minimum +- Root access +- Network access to your Proxmox server + +## Update Mechanism + +Depl0y includes an automatic update system: + +1. **Check for Updates** + - Go to Settings → System Updates + - Click "🔍 Check for Updates" + +2. **Install Updates** + - If an update is available, click "⬇️ Install Update" + - The system will download from deploy.agit8or.net + - Service restarts automatically + - Page reloads after update completes + +## Source Server + +The main Depl0y server is hosted at: +- **URL:** http://deploy.agit8or.net +- **Purpose:** Source for installations and updates +- **Updates:** All instances pull updates from this server + +## Troubleshooting + +### Installation Failed +```bash +# Check logs +sudo journalctl -u depl0y-backend -n 50 + +# Restart service +sudo systemctl restart depl0y-backend + +# Check nginx +sudo nginx -t +sudo systemctl status nginx +``` + +### Cannot Access Web Interface +```bash +# Check if service is running +sudo systemctl status depl0y-backend + +# Check nginx +sudo systemctl status nginx + +# Check firewall +sudo ufw status +sudo ufw allow 80/tcp +``` + +### Database Issues +```bash +# Reset database (WARNING: Deletes all data!) +sudo systemctl stop depl0y-backend +sudo rm -rf /var/lib/depl0y/db/* +sudo systemctl start depl0y-backend +``` + +## Uninstallation + +To completely remove Depl0y: + +```bash +# Stop services +sudo systemctl stop depl0y-backend +sudo systemctl disable depl0y-backend + +# Remove files +sudo rm -rf /opt/depl0y +sudo rm -rf /var/lib/depl0y +sudo rm /etc/systemd/system/depl0y-backend.service +sudo rm /etc/nginx/sites-enabled/depl0y +sudo rm /etc/nginx/sites-available/depl0y +sudo rm /etc/sudoers.d/depl0y + +# Remove user +sudo userdel -r depl0y + +# Reload services +sudo systemctl daemon-reload +sudo systemctl restart nginx +``` + +## Support + +- **Documentation:** http://deploy.agit8or.net/docs +- **Issues:** Report bugs and request features on GitHub +- **Updates:** Automatic updates from deploy.agit8or.net + +--- + +**Depl0y** - Automated VM Deployment Panel for Proxmox VE diff --git a/docs/PROXMOX_API_TOKENS.md b/docs/PROXMOX_API_TOKENS.md new file mode 100644 index 0000000..a160d42 --- /dev/null +++ b/docs/PROXMOX_API_TOKENS.md @@ -0,0 +1,128 @@ +# Proxmox API Tokens for 2FA Authentication + +When your Proxmox VE server has Two-Factor Authentication (2FA) enabled, you need to use **API Tokens** instead of password authentication for programmatic access. + +## Why API Tokens? + +- **Bypass 2FA**: API tokens don't require 2FA codes, making them perfect for automated systems +- **More Secure**: Can be revoked without changing your main password +- **Fine-grained Permissions**: Can be restricted to specific operations +- **Audit Trail**: API token usage is logged separately + +## Creating an API Token in Proxmox + +### Step 1: Log into Proxmox Web Interface + +1. Navigate to your Proxmox web interface: `https://your-proxmox-host:8006` +2. Log in with your credentials (including 2FA if enabled) + +### Step 2: Navigate to API Tokens + +1. Click on **Datacenter** in the left sidebar +2. Expand **Permissions** +3. Click on **API Tokens** + +### Step 3: Create a New Token + +1. Click the **Add** button at the top +2. Fill in the token details: + - **User**: Select the user (e.g., `root@pam`) + - **Token ID**: Give it a meaningful name (e.g., `depl0y`) + - **Privilege Separation**: **UNCHECK** this box (important!) + - Unchecking this gives the token the same permissions as the user + - Checking it would create a token with limited permissions + - **Expiration**: Leave empty for no expiration, or set an expiration date + +3. Click **Add** + +### Step 4: Save the Token Secret + +**IMPORTANT**: After clicking Add, you'll see a screen showing the **Token Secret**. + +``` +Token ID: root@pam!depl0y +Token Secret: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx +``` + +**Copy and save this secret immediately!** You won't be able to see it again. + +## Using API Tokens in Depl0y + +When adding a Proxmox host in Depl0y: + +1. Go to **Proxmox Hosts** → **+ Add Host** +2. Fill in basic details: + - Name: Any name for your reference + - Hostname/IP: Your Proxmox server address + - Port: 8006 (default) + - Username: `root@pam` (the user who owns the token) + +3. **Check** the box: "Use API Token (recommended for 2FA-enabled Proxmox)" + +4. Enter token details: + - **API Token ID**: You can use either format: + - **Full format**: `root@pam!depl0y` (the complete Token ID from Proxmox) + - **Short format**: `depl0y` (just the token name) + - **API Token Secret**: The UUID-like secret you copied earlier + +5. Optionally check "Verify SSL Certificate" if you have a valid SSL cert + +6. Click **Add Host** + +## Example + +If Proxmox shows your token as: +``` +Token ID: root@pam!mytoken +Token Secret: 12345678-1234-1234-1234-123456789abc +``` + +In Depl0y, you can enter **either**: + +**Option 1 (Full format - Recommended):** +- Username: `root@pam` (can be anything, will be extracted from token) +- API Token ID: `root@pam!mytoken` +- API Token Secret: `12345678-1234-1234-1234-123456789abc` + +**Option 2 (Short format):** +- Username: `root@pam` +- API Token ID: `mytoken` +- API Token Secret: `12345678-1234-1234-1234-123456789abc` + +## Troubleshooting + +### Connection Failed +- Make sure "Privilege Separation" was UNCHECKED when creating the token +- Confirm the token hasn't expired +- Check that the Proxmox host is reachable from your Depl0y server +- Verify the token secret is correct (it's case-sensitive) +- Try using the full token format: `root@pam!tokenname` + +### Permission Denied +- Ensure "Privilege Separation" was unchecked when creating the token +- Verify the user account has sufficient permissions +- Check Proxmox logs: `/var/log/pve/tasks/` + +### Token Not Working After Creation +- API tokens may need a few seconds to become active +- Try refreshing the Proxmox web interface +- Log out and back in to Proxmox + +## Security Best Practices + +1. **Use Separate Tokens**: Create different tokens for different applications +2. **Set Expiration**: Consider setting expiration dates for tokens +3. **Revoke Unused Tokens**: Regularly audit and remove tokens you no longer use +4. **Store Securely**: Keep token secrets secure, like passwords +5. **Monitor Usage**: Check Proxmox logs for API token usage + +## Revoking a Token + +To revoke an API token: + +1. Go to **Datacenter** → **Permissions** → **API Tokens** +2. Select the token you want to revoke +3. Click **Remove** +4. Confirm the removal + +The token will be immediately revoked and can no longer be used. diff --git a/scripts/install-native.sh b/scripts/install-native.sh new file mode 100644 index 0000000..afac73e --- /dev/null +++ b/scripts/install-native.sh @@ -0,0 +1,344 @@ +#!/bin/bash + +# Depl0y Native Installation Script (No Docker) +# This script sets up Depl0y directly on the host system + +set -e + +echo "=========================================" +echo " Depl0y Native Installation" +echo "=========================================" +echo "" + +# Check if running as root +if [ "$EUID" -ne 0 ]; then + echo "Please run as root (use sudo)" + exit 1 +fi + +# Get the script directory +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(dirname "$SCRIPT_DIR")" + +echo "Installing to: $PROJECT_DIR" +echo "" + +# Detect OS +if [ -f /etc/os-release ]; then + . /etc/os-release + OS=$ID +else + echo "Cannot detect OS. Exiting." + exit 1 +fi + +echo "Detected OS: $OS" +echo "" + +# Install system dependencies +echo "Installing system dependencies..." +if [ "$OS" = "ubuntu" ] || [ "$OS" = "debian" ]; then + apt-get update + apt-get install -y \ + python3 \ + python3-venv \ + python3-pip \ + mariadb-server \ + nginx \ + nodejs \ + npm \ + gcc \ + g++ \ + libmariadb-dev \ + pkg-config \ + git +elif [ "$OS" = "centos" ] || [ "$OS" = "rhel" ] || [ "$OS" = "rocky" ] || [ "$OS" = "almalinux" ]; then + yum install -y epel-release + yum install -y \ + python311 \ + python3-pip \ + mariadb-server \ + nginx \ + nodejs \ + npm \ + gcc \ + gcc-c++ \ + mariadb-devel \ + git +else + echo "Unsupported OS: $OS" + exit 1 +fi + +echo "System dependencies installed" +echo "" + +# Start and enable MariaDB +echo "Configuring MariaDB..." +systemctl enable mariadb +systemctl start mariadb + +# Secure MariaDB installation +DB_ROOT_PASSWORD=$(openssl rand -hex 16) +mysql -e "ALTER USER 'root'@'localhost' IDENTIFIED BY '${DB_ROOT_PASSWORD}';" || true +mysql -u root -p"${DB_ROOT_PASSWORD}" -e "DELETE FROM mysql.user WHERE User='';" || true +mysql -u root -p"${DB_ROOT_PASSWORD}" -e "DELETE FROM mysql.user WHERE User='root' AND Host NOT IN ('localhost', '127.0.0.1', '::1');" || true +mysql -u root -p"${DB_ROOT_PASSWORD}" -e "DROP DATABASE IF EXISTS test;" || true +mysql -u root -p"${DB_ROOT_PASSWORD}" -e "DELETE FROM mysql.db WHERE Db='test' OR Db='test\\_%';" || true +mysql -u root -p"${DB_ROOT_PASSWORD}" -e "FLUSH PRIVILEGES;" || true + +# Create database and user +DB_PASSWORD=$(openssl rand -hex 16) +mysql -u root -p"${DB_ROOT_PASSWORD}" << EOF +CREATE DATABASE IF NOT EXISTS depl0y; +CREATE USER IF NOT EXISTS 'depl0y'@'localhost' IDENTIFIED BY '${DB_PASSWORD}'; +GRANT ALL PRIVILEGES ON depl0y.* TO 'depl0y'@'localhost'; +FLUSH PRIVILEGES; +EOF + +echo "MariaDB configured" +echo "" + +# Create application user +echo "Creating depl0y user..." +useradd -r -s /bin/bash -d /opt/depl0y -m depl0y || true + +# Create directories +echo "Creating directories..." +mkdir -p /opt/depl0y/{backend,frontend} +mkdir -p /var/lib/depl0y/{isos,cloud-init,ssh_keys} +mkdir -p /var/log/depl0y +mkdir -p /etc/depl0y + +# Copy application files +echo "Copying application files..." +cp -r "$PROJECT_DIR/backend"/* /opt/depl0y/backend/ +cp -r "$PROJECT_DIR/frontend"/* /opt/depl0y/frontend/ + +# Set permissions +chown -R depl0y:depl0y /opt/depl0y +chown -R depl0y:depl0y /var/lib/depl0y +chown -R depl0y:depl0y /var/log/depl0y + +# Generate secrets +echo "Generating secrets..." +SECRET_KEY=$(openssl rand -hex 32) +ENCRYPTION_KEY=$(python3 -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())") + +# Create environment file +cat > /etc/depl0y/config.env << EOF +# Database Configuration +DATABASE_URL=mysql+pymysql://depl0y:${DB_PASSWORD}@localhost:3306/depl0y + +# Security +SECRET_KEY=${SECRET_KEY} +ENCRYPTION_KEY=${ENCRYPTION_KEY} +DEBUG=false + +# Application Settings +LOG_LEVEL=INFO +LOG_FILE=/var/log/depl0y/app.log + +# Storage Paths +ISO_STORAGE_PATH=/var/lib/depl0y/isos +CLOUDINIT_TEMPLATE_PATH=/var/lib/depl0y/cloud-init +SSH_KEY_PATH=/var/lib/depl0y/ssh_keys + +# API +API_V1_PREFIX=/api/v1 +EOF + +chmod 600 /etc/depl0y/config.env +chown depl0y:depl0y /etc/depl0y/config.env + +# Install Python dependencies +echo "Installing Python dependencies..." +cd /opt/depl0y/backend +sudo -u depl0y python3 -m venv venv +sudo -u depl0y /opt/depl0y/backend/venv/bin/pip install --upgrade pip +sudo -u depl0y /opt/depl0y/backend/venv/bin/pip install -r requirements.txt + +# Initialize database +echo "Initializing database..." +cd /opt/depl0y/backend +sudo -u depl0y bash -c "source venv/bin/activate && source /etc/depl0y/config.env && python3 -c 'from app.core.database import init_db; init_db()'" + +# Build frontend +echo "Building frontend..." +cd /opt/depl0y/frontend +sudo -u depl0y npm install +sudo -u depl0y npm run build + +# Create systemd service for backend +echo "Creating systemd service..." +cat > /etc/systemd/system/depl0y-backend.service << 'EOF' +[Unit] +Description=Depl0y Backend API +After=network.target mariadb.service +Wants=mariadb.service + +[Service] +Type=simple +User=depl0y +Group=depl0y +WorkingDirectory=/opt/depl0y/backend +EnvironmentFile=/etc/depl0y/config.env +ExecStart=/opt/depl0y/backend/venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8000 +Restart=always +RestartSec=10 + +[Install] +WantedBy=multi-user.target +EOF + +# Configure Nginx +echo "Configuring Nginx..." +cat > /etc/nginx/sites-available/depl0y << 'EOF' +server { + listen 80; + server_name _; + + client_max_body_size 10G; + + # Frontend + location / { + root /opt/depl0y/frontend/dist; + try_files $uri $uri/ /index.html; + + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + } + + # Backend API + location /api { + proxy_pass http://127.0.0.1:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_connect_timeout 300s; + proxy_send_timeout 300s; + proxy_read_timeout 300s; + } + + # Health check + location /health { + proxy_pass http://127.0.0.1:8000/health; + access_log off; + } + + access_log /var/log/nginx/depl0y_access.log; + error_log /var/log/nginx/depl0y_error.log; +} +EOF + +# Enable nginx site +if [ "$OS" = "ubuntu" ] || [ "$OS" = "debian" ]; then + ln -sf /etc/nginx/sites-available/depl0y /etc/nginx/sites-enabled/ + rm -f /etc/nginx/sites-enabled/default +else + cp /etc/nginx/sites-available/depl0y /etc/nginx/conf.d/depl0y.conf +fi + +# Test nginx configuration +nginx -t + +# Create admin user +echo "" +echo "=========================================" +echo "Creating admin user..." +echo "=========================================" +read -p "Enter admin username (default: admin): " ADMIN_USER +ADMIN_USER=${ADMIN_USER:-admin} + +read -p "Enter admin email: " ADMIN_EMAIL +while [ -z "$ADMIN_EMAIL" ]; do + echo "Email cannot be empty" + read -p "Enter admin email: " ADMIN_EMAIL +done + +read -sp "Enter admin password: " ADMIN_PASSWORD +echo "" +while [ ${#ADMIN_PASSWORD} -lt 8 ]; do + echo "Password must be at least 8 characters" + read -sp "Enter admin password: " ADMIN_PASSWORD + echo "" +done + +cd /opt/depl0y/backend +sudo -u depl0y bash -c "source venv/bin/activate && source /etc/depl0y/config.env && python3" << PYEOF +from app.core.database import SessionLocal +from app.models import User, UserRole +from app.core.security import get_password_hash + +db = SessionLocal() +try: + existing_user = db.query(User).filter(User.username == "$ADMIN_USER").first() + if existing_user: + print("User '$ADMIN_USER' already exists") + else: + admin = User( + username="$ADMIN_USER", + email="$ADMIN_EMAIL", + hashed_password=get_password_hash("$ADMIN_PASSWORD"), + role=UserRole.ADMIN, + is_active=True + ) + db.add(admin) + db.commit() + print("Admin user created successfully") +except Exception as e: + print(f"Error creating admin user: {e}") + import traceback + traceback.print_exc() +finally: + db.close() +PYEOF + +# Enable and start services +echo "" +echo "Starting services..." +systemctl daemon-reload +systemctl enable depl0y-backend +systemctl start depl0y-backend +systemctl restart nginx + +# Wait for backend to start +echo "Waiting for backend to start..." +sleep 5 + +# Check service status +echo "" +echo "Checking service status..." +systemctl status depl0y-backend --no-pager || true + +echo "" +echo "=========================================" +echo " Installation Complete!" +echo "=========================================" +echo "" +echo "Depl0y is now running!" +echo "" +SERVER_IP=$(hostname -I | awk '{print $1}') +echo "Access the web interface at: http://${SERVER_IP}" +echo "" +echo "Login credentials:" +echo " Username: $ADMIN_USER" +echo " Password: (the one you just entered)" +echo "" +echo "Service management:" +echo " Status: sudo systemctl status depl0y-backend" +echo " Stop: sudo systemctl stop depl0y-backend" +echo " Start: sudo systemctl start depl0y-backend" +echo " Restart: sudo systemctl restart depl0y-backend" +echo " Logs: sudo journalctl -u depl0y-backend -f" +echo "" +echo "Database credentials saved to: /etc/depl0y/config.env" +echo "MariaDB root password: ${DB_ROOT_PASSWORD}" +echo "" +echo "=========================================" diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100644 index 0000000..9e64133 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,652 @@ +#!/bin/bash +# +# Depl0y - Automated VM Deployment Panel Installer +# Complete automated installation - no manual steps required! +# One-line install: curl -fsSL http://deploy.agit8or.net/downloads/install.sh | sudo bash +# + +set -e + +# Installer version for tracking +INSTALLER_VERSION="1.1.0" +INSTALLER_BUILD="$(date +%Y%m%d%H%M%S)" + +echo "╔══════════════════════════════════════════════════════════╗" +echo "║ ║" +echo "║ ██████╗ ███████╗██████╗ ██╗ ██████╗ ██╗ ██╗ ║" +echo "║ ██╔══██╗██╔════╝██╔══██╗██║ ██╔═████╗╚██╗ ██╔╝ ║" +echo "║ ██║ ██║█████╗ ██████╔╝██║ ██║██╔██║ ╚████╔╝ ║" +echo "║ ██║ ██║██╔══╝ ██╔═══╝ ██║ ████╔╝██║ ╚██╔╝ ║" +echo "║ ██████╔╝███████╗██║ ███████╗╚██████╔╝ ██║ ║" +echo "║ ╚═════╝ ╚══════╝╚═╝ ╚══════╝ ╚═════╝ ╚═╝ ║" +echo "║ ║" +echo "║ Automated VM Deployment Panel for Proxmox VE ║" +echo "║ https://deploy.agit8or.net ║" +echo "║ Version 1.1.5 ║" +echo "║ ║" +echo "╚══════════════════════════════════════════════════════════╝" +echo "" +echo "This installer will:" +echo " ✓ Install all dependencies" +echo " ✓ Set up Depl0y application" +echo " ✓ Configure cloud images (optional)" +echo " ✓ Configure inter-node SSH (optional)" +echo " ✓ No manual steps required!" +echo "" + +# Check if running as root +if [ "$EUID" -ne 0 ]; then + echo "ERROR: Please run as root (use sudo)" + exit 1 +fi + +echo "🚀 Starting Depl0y installation..." +echo "" + +# Check if Depl0y is already installed +UPGRADE_MODE=false +if [ -d "/opt/depl0y" ] && [ -f "/etc/systemd/system/depl0y-backend.service" ]; then + UPGRADE_MODE=true + echo "📦 Existing Depl0y installation detected" + echo " This will upgrade your installation while preserving your database" + echo "" +fi + +# Detect OS +if [ -f /etc/os-release ]; then + . /etc/os-release + OS=$ID + VER=$VERSION_ID +else + echo "ERROR: Cannot detect OS" + exit 1 +fi + +echo "✓ Detected OS: $OS $VER" + +# Check if Ubuntu/Debian +if [[ "$OS" != "ubuntu" && "$OS" != "debian" ]]; then + echo "ERROR: This installer currently only supports Ubuntu and Debian" + exit 1 +fi + +# Update system +echo "" +echo "📦 Updating system packages..." +apt-get update -qq + +# Install ALL dependencies +echo "📦 Installing dependencies..." +echo " This includes: Python, Node.js, nginx, SQLite, PDF libraries, and more" +apt-get install -y -qq \ + python3 \ + python3-pip \ + python3-venv \ + python3-dev \ + build-essential \ + nginx \ + nodejs \ + npm \ + sqlite3 \ + curl \ + wget \ + git \ + sshpass \ + openssh-client \ + at \ + ca-certificates \ + gnupg \ + lsb-release \ + libpango-1.0-0 \ + libpangocairo-1.0-0 \ + libgdk-pixbuf2.0-0 \ + libffi-dev \ + shared-mime-info + +echo "✓ Dependencies installed" + +# Create depl0y user +echo "" +echo "👤 Creating depl0y system user..." +if ! id -u depl0y > /dev/null 2>&1; then + useradd -r -m -d /opt/depl0y -s /bin/bash depl0y + echo "✓ User 'depl0y' created" +else + echo "✓ User 'depl0y' already exists" +fi + +# If upgrading, stop the backend service and backup encryption keys +if [ "$UPGRADE_MODE" = true ]; then + echo "🔄 Preparing for upgrade..." + + # Clear any stale Python cache first + echo "🗑️ Clearing old Python cache..." + find /opt/depl0y/backend -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true + find /opt/depl0y/backend -type f -name "*.pyc" -delete 2>/dev/null || true + echo "✓ Cache cleared" + + if systemctl is-active --quiet depl0y-backend; then + systemctl stop depl0y-backend + echo "✓ Stopped backend service" + fi + + # Backup encryption keys from existing service + KEYS_VALID=false + if [ -f /etc/systemd/system/depl0y-backend.service ]; then + echo "🔍 Extracting existing encryption keys..." + EXISTING_SECRET=$(grep "SECRET_KEY=" /etc/systemd/system/depl0y-backend.service | sed 's/.*SECRET_KEY=\([^"]*\).*/\1/') + EXISTING_ENCRYPTION=$(grep "ENCRYPTION_KEY=" /etc/systemd/system/depl0y-backend.service | sed 's/.*ENCRYPTION_KEY=\([^"]*\).*/\1/') + + if [ -n "$EXISTING_SECRET" ] && [ -n "$EXISTING_ENCRYPTION" ]; then + echo " Found existing keys, validating..." + # Validate the encryption key + if python3 -c "from cryptography.fernet import Fernet; Fernet('${EXISTING_ENCRYPTION}'.encode())" 2>/dev/null; then + echo "✓ Existing encryption keys are valid and will be preserved" + KEYS_VALID=true + else + echo "⚠️ Existing encryption keys are INVALID (wrong format)" + echo " Will generate new keys - you'll need to re-enter Proxmox credentials" + fi + else + echo "⚠️ Could not extract existing keys from service file" + echo " Will generate new keys" + fi + fi +fi + +# Download latest Depl0y +echo "" +echo "⬇️ Downloading Depl0y from deploy.agit8or.net..." +cd /tmp +rm -f depl0y-latest.tar.gz +# Add cache-busting timestamp to ensure fresh download +CACHE_BUST=$(date +%s) +curl -fsSL "http://deploy.agit8or.net/api/v1/system-updates/download?v=${CACHE_BUST}" -o depl0y-latest.tar.gz + +echo "📦 Extracting Depl0y..." +rm -rf /tmp/depl0y-install +mkdir -p /tmp/depl0y-install +cd /tmp/depl0y-install +tar -xzf /tmp/depl0y-latest.tar.gz + +# Install backend +echo "" +echo "🔧 Installing backend..." +mkdir -p /opt/depl0y +chmod 755 /opt/depl0y # Allow nginx to traverse +cp -r backend /opt/depl0y/ +chown -R depl0y:depl0y /opt/depl0y/backend +chmod -R 755 /opt/depl0y/backend + +# Create Python virtual environment +echo "🐍 Setting up Python environment..." +cd /opt/depl0y/backend +sudo -u depl0y python3 -m venv venv +sudo -u depl0y venv/bin/pip install --upgrade pip -q +sudo -u depl0y venv/bin/pip install -r requirements.txt -q + +# Install additional Python packages for features +echo " Installing additional Python packages..." +sudo -u depl0y venv/bin/pip install -q weasyprint markdown requests + +echo "✓ Backend installed" + +# Install scripts +echo "" +echo "📜 Installing scripts..." +if [ -d "/tmp/depl0y-install/scripts" ]; then + mkdir -p /opt/depl0y/scripts + cp -r /tmp/depl0y-install/scripts/* /opt/depl0y/scripts/ + chmod -R 755 /opt/depl0y/scripts + echo "✓ Scripts installed" +else + echo "⚠️ No scripts directory found in package" +fi + +# Install frontend +echo "" +echo "🎨 Installing frontend..." + +if [ "$UPGRADE_MODE" = true ]; then + # During upgrade, use pre-built frontend from package (much faster!) + echo " Using pre-built frontend from package..." + if [ -d "/tmp/depl0y-install/frontend/dist" ]; then + mkdir -p /opt/depl0y/frontend/dist + chmod 755 /opt/depl0y/frontend + cp -r /tmp/depl0y-install/frontend/dist/* /opt/depl0y/frontend/dist/ + chown -R www-data:www-data /opt/depl0y/frontend/dist + chmod -R 755 /opt/depl0y/frontend/dist + echo "✓ Frontend installed (pre-built)" + else + echo "⚠️ Pre-built frontend not found, building from source..." + cd /tmp/depl0y-install/frontend + npm install --silent + npm run build + mkdir -p /opt/depl0y/frontend/dist + chmod 755 /opt/depl0y/frontend + cp -r dist/* /opt/depl0y/frontend/dist/ + chown -R www-data:www-data /opt/depl0y/frontend/dist + chmod -R 755 /opt/depl0y/frontend/dist + echo "✓ Frontend installed (built from source)" + fi +else + # Fresh install - build frontend from source + cd /tmp/depl0y-install/frontend + npm install --silent + npm run build + mkdir -p /opt/depl0y/frontend/dist + chmod 755 /opt/depl0y/frontend + cp -r dist/* /opt/depl0y/frontend/dist/ + chown -R www-data:www-data /opt/depl0y/frontend/dist + chmod -R 755 /opt/depl0y/frontend/dist + echo "✓ Frontend installed" +fi + +# Create database directory +echo "" +echo "💾 Setting up database..." +mkdir -p /var/lib/depl0y/db +mkdir -p /var/lib/depl0y/cloud-images +mkdir -p /var/lib/depl0y/isos +mkdir -p /var/lib/depl0y/ssh_keys +mkdir -p /var/log/depl0y +chown -R depl0y:depl0y /var/lib/depl0y +chown -R depl0y:depl0y /var/log/depl0y +chmod -R 755 /var/lib/depl0y + +echo "✓ Database directories created" + +# Copy documentation +echo "" +echo "📚 Installing documentation..." +if [ -d "/tmp/depl0y-install/docs" ]; then + mkdir -p /opt/depl0y/docs + cp -r /tmp/depl0y-install/docs/* /opt/depl0y/docs/ + chown -R depl0y:depl0y /opt/depl0y/docs + echo "✓ Documentation installed" +fi + +# Generate or reuse encryption keys +echo "" +if [ "$UPGRADE_MODE" = true ] && [ "$KEYS_VALID" = true ]; then + echo "🔐 Reusing existing encryption keys..." + SECRET_KEY="$EXISTING_SECRET" + ENCRYPTION_KEY="$EXISTING_ENCRYPTION" + echo "✓ Existing encryption keys preserved" +else + echo "🔐 Generating new encryption keys..." + SECRET_KEY=$(python3 -c 'import secrets; print(secrets.token_urlsafe(32))') + ENCRYPTION_KEY=$(python3 -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())') + echo "✓ New encryption keys generated" + + if [ "$UPGRADE_MODE" = true ]; then + echo "" + echo "⚠️ IMPORTANT: New encryption keys have been generated" + echo " This means you will need to:" + echo " • Log in again (previous sessions are now invalid)" + echo " • Re-enter all Proxmox host credentials" + echo " • Your database and settings are preserved" + fi +fi + +# Create systemd service +echo "" +echo "⚙️ Creating systemd service..." +cat > /etc/systemd/system/depl0y-backend.service << SVCEOF +[Unit] +Description=Depl0y Backend API +After=network.target + +[Service] +Type=simple +User=depl0y +Group=depl0y +WorkingDirectory=/opt/depl0y/backend +Environment="PATH=/opt/depl0y/backend/venv/bin" +Environment="SECRET_KEY=${SECRET_KEY}" +Environment="ENCRYPTION_KEY=${ENCRYPTION_KEY}" +ExecStart=/opt/depl0y/backend/venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8000 +Restart=always +RestartSec=10 + +[Install] +WantedBy=multi-user.target +SVCEOF + +# Clear Python cache to ensure new code is loaded +if [ "$UPGRADE_MODE" = true ]; then + echo "🗑️ Clearing Python cache again (ensuring fresh code)..." +else + echo "🗑️ Clearing Python cache..." +fi +find /opt/depl0y/backend -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true +find /opt/depl0y/backend -type f -name "*.pyc" -delete 2>/dev/null || true + +systemctl daemon-reload +systemctl enable depl0y-backend + +# Use restart to ensure service loads new code (important for upgrades) +if [ "$UPGRADE_MODE" = true ]; then + echo "🔄 Restarting backend service with new code..." + systemctl restart depl0y-backend +else + echo "🚀 Starting backend service..." + systemctl start depl0y-backend +fi + +echo "✓ Backend service started" + +# Configure nginx +echo "" +echo "🌐 Configuring nginx..." +cat > /etc/nginx/sites-available/depl0y << 'NGEOF' +server { + listen 80; + server_name _; + + client_max_body_size 10G; + + # Frontend + location / { + root /opt/depl0y/frontend/dist; + try_files $uri $uri/ /index.html; + + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + } + + # Backend API + location /api { + proxy_pass http://127.0.0.1:8000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_cache_bypass $http_upgrade; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_connect_timeout 300s; + proxy_send_timeout 300s; + proxy_read_timeout 300s; + } + + access_log /var/log/nginx/depl0y_access.log; + error_log /var/log/nginx/depl0y_error.log; +} +NGEOF + +ln -sf /etc/nginx/sites-available/depl0y /etc/nginx/sites-enabled/depl0y +rm -f /etc/nginx/sites-enabled/default + +nginx -t +systemctl restart nginx + +echo "✓ Nginx configured" + +# Setup sudoers for depl0y user +echo "" +echo "🔐 Configuring permissions..." +cat > /etc/sudoers.d/depl0y << 'SUDOEOF' +# Depl0y user sudo permissions +depl0y ALL=(ALL) NOPASSWD: /usr/bin/apt-get install -y -qq sshpass +depl0y ALL=(ALL) NOPASSWD: /usr/bin/which sshpass +depl0y ALL=(depl0y) NOPASSWD: /usr/bin/mkdir -p /opt/depl0y/.ssh +depl0y ALL=(depl0y) NOPASSWD: /usr/bin/ssh-keygen * +depl0y ALL=(depl0y) NOPASSWD: /usr/bin/sshpass * +depl0y ALL=(depl0y) NOPASSWD: /usr/bin/ssh * +depl0y ALL=(depl0y) NOPASSWD: /usr/bin/ssh-copy-id * +depl0y ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart depl0y-backend +depl0y ALL=(ALL) NOPASSWD: /bin/systemctl restart depl0y-backend +depl0y ALL=(ALL) NOPASSWD: /usr/bin/systemctl status depl0y-backend +depl0y ALL=(ALL) NOPASSWD: /bin/bash /tmp/depl0y-update-install.sh +depl0y ALL=(ALL) NOPASSWD: /opt/depl0y/scripts/update-wrapper.sh * +depl0y ALL=(ALL) NOPASSWD: /usr/bin/journalctl -u depl0y-backend * +SUDOEOF + +chmod 440 /etc/sudoers.d/depl0y +visudo -c + +echo "✓ Permissions configured" + +# Wait for backend to start +echo "" +echo "⏳ Waiting for backend to start..." +sleep 5 + +# Check if backend is running +if systemctl is-active --quiet depl0y-backend; then + echo "✓ Backend is running" +else + echo "" + echo "❌ ERROR: Backend failed to start!" + echo "" + echo "Last 30 lines of backend logs:" + journalctl -u depl0y-backend -n 30 --no-pager + echo "" + echo "Installation failed. Please check the logs above for errors." + exit 1 +fi + +# Create default admin user +echo "" +echo "👤 Creating default admin user..." +cd /opt/depl0y/backend +if sudo -u depl0y /opt/depl0y/backend/venv/bin/python3 create_admin.py; then + echo "✓ Default credentials: admin / admin" +else + echo "❌ ERROR: Failed to create admin user!" + echo "Check permissions and database connection." + exit 1 +fi + +# Initialize system settings in database +echo "" +echo "⚙️ Initializing system settings..." +sudo -u depl0y sqlite3 /var/lib/depl0y/db/depl0y.db "CREATE TABLE IF NOT EXISTS system_settings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + key VARCHAR(100) UNIQUE NOT NULL, + value TEXT NOT NULL, + description TEXT, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +);" 2>/dev/null || true + +sudo -u depl0y sqlite3 /var/lib/depl0y/db/depl0y.db "INSERT OR REPLACE INTO system_settings (key, value, description) VALUES + ('app_version', '1.1.9', 'Current application version'), + ('app_name', 'Depl0y', 'Application name');" 2>/dev/null || true + +echo "✓ System settings initialized" + +# Optional Proxmox setup +echo "" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo " Optional: Proxmox Integration Setup" +echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +echo "" +echo "Would you like to configure Proxmox integration now?" +echo "This will:" +echo " • Set up cloud images for fast VM deployment" +echo " • Configure inter-node SSH for multi-node clusters" +echo "" +read -p "Configure Proxmox now? (y/N): " -n 1 -r SETUP_PROXMOX < /dev/tty || SETUP_PROXMOX="N" +echo "" + +if [[ $SETUP_PROXMOX =~ ^[Yy]$ ]]; then + echo "" + echo "📋 Proxmox Configuration" + echo "" + + # Get Proxmox details + read -p "Enter Proxmox hostname or IP: " PROXMOX_HOST < /dev/tty + read -p "Enter Proxmox root username [root]: " PROXMOX_USER < /dev/tty + PROXMOX_USER=${PROXMOX_USER:-root} + + echo "" + echo "⚠️ Note: Password will be used to set up SSH keys only" + read -s -p "Enter Proxmox root password: " PROXMOX_PASSWORD < /dev/tty + echo "" + echo "" + + # Setup SSH keys for depl0y user + echo "🔑 Setting up SSH keys..." + if [ ! -f "/opt/depl0y/.ssh/id_rsa" ]; then + sudo -u depl0y mkdir -p /opt/depl0y/.ssh + sudo -u depl0y ssh-keygen -t rsa -b 4096 -f /opt/depl0y/.ssh/id_rsa -N "" -q + echo "✓ SSH keys generated" + else + echo "✓ SSH keys already exist" + fi + + # Copy SSH key to Proxmox + echo "📤 Copying SSH key to Proxmox..." + sudo -u depl0y sshpass -p "$PROXMOX_PASSWORD" ssh-copy-id -o StrictHostKeyChecking=no "${PROXMOX_USER}@${PROXMOX_HOST}" 2>/dev/null || { + echo "⚠️ Failed to copy SSH key. You may need to do this manually later." + } + + # Test SSH connection + if sudo -u depl0y ssh -o BatchMode=yes -o ConnectTimeout=5 -o StrictHostKeyChecking=no "${PROXMOX_USER}@${PROXMOX_HOST}" "echo SSH_OK" 2>/dev/null | grep -q "SSH_OK"; then + echo "✓ SSH connection verified" + + # Setup cloud images + echo "" + echo "☁️ Setting up cloud images..." + sudo -u depl0y ssh -o StrictHostKeyChecking=no "${PROXMOX_USER}@${PROXMOX_HOST}" 'bash -s' << 'CLOUDEOF' +# Check if cloud-init is available +if ! which cloud-init >/dev/null 2>&1; then + apt-get update -qq + apt-get install -y -qq cloud-init +fi + +# Create cloud-init snippet storage if needed +mkdir -p /var/lib/vz/snippets +chmod 755 /var/lib/vz/snippets + +echo "✓ Cloud images configured" +CLOUDEOF + + # Setup inter-node SSH for cluster + echo "" + echo "🔗 Setting up inter-node SSH for cluster..." + sudo -u depl0y ssh -o StrictHostKeyChecking=no "${PROXMOX_USER}@${PROXMOX_HOST}" 'bash -s' << 'SSHEOF' +# Get cluster nodes +NODES=$(pvesh get /cluster/status --output-format json 2>/dev/null | grep -o '"name":"[^"]*"' | cut -d'"' -f4 | grep -v "^$" || echo "") + +if [ -n "$NODES" ]; then + echo " Found cluster nodes: $NODES" + + # Generate SSH key on this node if needed + if [ ! -f ~/.ssh/id_rsa ]; then + ssh-keygen -t rsa -b 4096 -f ~/.ssh/id_rsa -N "" -q + fi + + # Copy key to other nodes + for node in $NODES; do + if [ "$node" != "$(hostname)" ]; then + echo " Setting up SSH to $node..." + ssh-copy-id -o StrictHostKeyChecking=no "root@$node" 2>/dev/null || true + fi + done + + echo "✓ Inter-node SSH configured" +else + echo " Single node or cluster not configured, skipping" +fi +SSHEOF + + echo "" + echo "✓ Proxmox integration complete!" + else + echo "⚠️ SSH connection failed. You can set this up later via Settings in the web interface." + fi +else + echo "" + echo "⏭️ Skipping Proxmox setup. You can configure this later via:" + echo " Settings → Cloud Images" + echo " Settings → Proxmox Cluster SSH" +fi + +# Cleanup +echo "" +echo "🧹 Cleaning up..." +cd / +rm -rf /tmp/depl0y-install +rm -f /tmp/depl0y-latest.tar.gz + +# Final restart for upgrades to ensure new code is loaded +if [ "$UPGRADE_MODE" = true ]; then + echo "" + echo "🔄 Final restart to load new code..." + find /opt/depl0y/backend -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true + find /opt/depl0y/backend -type f -name "*.pyc" -delete 2>/dev/null || true + systemctl restart depl0y-backend + sleep 3 + if systemctl is-active --quiet depl0y-backend; then + echo "✓ Backend restarted successfully" + else + echo "⚠️ Backend restart may have issues, check logs" + fi +fi + +# Get IP address +IP=$(hostname -I | awk '{print $1}') + +echo "" +if [ "$UPGRADE_MODE" = true ]; then + echo "╔══════════════════════════════════════════════════════════╗" + echo "║ ║" + echo "║ ✅ UPGRADE COMPLETE ║" + echo "║ ║" + echo "╚══════════════════════════════════════════════════════════╝" + echo "" + echo "🎉 Depl0y v1.1.9 has been successfully upgraded!" + echo "" + echo "📍 Access Depl0y at:" + echo " http://$IP" + echo "" + echo "✨ What's new in v1.1.9:" + echo " • Automatic backend restart after upgrades" + echo " • No manual restart needed - updates apply immediately" + echo " • Fixed JavaScript error (version not defined)" + echo " • One-click automatic updates from Settings" + echo "" + echo "📚 Note:" + echo " • Your database has been preserved" + echo " • Your encryption keys have been preserved" + echo " • All users and settings retained" +else + echo "╔══════════════════════════════════════════════════════════╗" + echo "║ ║" + echo "║ ✅ INSTALLATION COMPLETE ║" + echo "║ ║" + echo "╚══════════════════════════════════════════════════════════╝" + echo "" + echo "🎉 Depl0y v1.1.9 has been successfully installed!" + echo "" + echo "📍 Access Depl0y at:" + echo " http://$IP" + echo "" + echo "👤 Default Credentials:" + echo " Username: admin" + echo " Password: admin" + echo " 🔐 2FA: Disabled (enable in Settings after login)" + echo " ⚠️ CHANGE PASSWORD IMMEDIATELY AFTER FIRST LOGIN!" + echo "" + echo "✨ Features:" + echo " • High Availability wizard" + echo " • Cloud image management" + echo " • Automated VM deployment" + echo "" + echo "📚 Next Steps:" + echo " 1. Access the web interface" + echo " 2. Change the default password" + echo " 3. Add your Proxmox host in Settings (if not done)" + echo " 4. Try the HA Setup Wizard!" +fi +echo "" +echo "📖 Documentation: http://$IP → Documentation" +echo "🆘 Support: Check /opt/depl0y/docs/" +echo "" +echo "Thank you for using Depl0y! 🚀" +echo "" diff --git a/scripts/quick-sqlite.sh b/scripts/quick-sqlite.sh new file mode 100644 index 0000000..0d8e4bb --- /dev/null +++ b/scripts/quick-sqlite.sh @@ -0,0 +1,187 @@ +#!/bin/bash + +# Depl0y Quick Setup with SQLite (simplest installation) + +set -e + +echo "=========================================" +echo " Depl0y Quick Setup (SQLite)" +echo "=========================================" +echo "" + +if [ "$EUID" -ne 0 ]; then + echo "Please run as root" + exit 1 +fi + +cd /home/administrator/depl0y + +# Create directories +echo "Creating directories..." +mkdir -p /opt/depl0y/{backend,frontend} +mkdir -p /var/lib/depl0y/{isos,cloud-init,ssh_keys,db} +mkdir -p /var/log/depl0y +mkdir -p /etc/depl0y + +# Copy files +echo "Copying files..." +cp -r backend/* /opt/depl0y/backend/ +cp -r frontend/* /opt/depl0y/frontend/ + +# Create user +useradd -r -s /bin/bash -d /opt/depl0y -m depl0y 2>/dev/null || true + +# Set permissions +chown -R depl0y:depl0y /opt/depl0y /var/lib/depl0y /var/log/depl0y + +# Generate config +echo "Generating configuration..." +SECRET_KEY=$(openssl rand -hex 32) +ENCRYPTION_KEY=$(python3 -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())") + +cat > /etc/depl0y/config.env << EOF +DATABASE_URL=sqlite:////var/lib/depl0y/db/depl0y.db +SECRET_KEY=${SECRET_KEY} +ENCRYPTION_KEY=${ENCRYPTION_KEY} +DEBUG=false +LOG_LEVEL=INFO +LOG_FILE=/var/log/depl0y/app.log +ISO_STORAGE_PATH=/var/lib/depl0y/isos +CLOUDINIT_TEMPLATE_PATH=/var/lib/depl0y/cloud-init +SSH_KEY_PATH=/var/lib/depl0y/ssh_keys +API_V1_PREFIX=/api/v1 +EOF + +chmod 600 /etc/depl0y/config.env +chown depl0y:depl0y /etc/depl0y/config.env + +# Install Python deps +echo "Installing Python dependencies..." +cd /opt/depl0y/backend +sudo -u depl0y python3 -m venv venv +sudo -u depl0y venv/bin/pip install --upgrade pip -q +sudo -u depl0y venv/bin/pip install -r requirements.txt -q + +echo "✓ Python dependencies installed" + +# Init database +echo "Initializing database..." +sudo -u depl0y bash -c "source venv/bin/activate && source /etc/depl0y/config.env && python3 -c 'from app.core.database import init_db; init_db()'" + +echo "✓ Database initialized" + +# Create admin +echo "Creating admin user..." +sudo -u depl0y bash -c "source venv/bin/activate && source /etc/depl0y/config.env && python3" << 'PYEOF' +from app.core.database import SessionLocal +from app.models import User, UserRole +from app.core.security import get_password_hash + +db = SessionLocal() +try: + admin = User( + username="admin", + email="admin@depl0y.local", + hashed_password=get_password_hash("Admin123!"), + role=UserRole.ADMIN, + is_active=True + ) + db.add(admin) + db.commit() + print("✓ Admin created") +except Exception as e: + print(f"Note: {e}") +finally: + db.close() +PYEOF + +# Build frontend +echo "Building frontend..." +cd /opt/depl0y/frontend +sudo -u depl0y npm install -q +sudo -u depl0y npm run build + +echo "✓ Frontend built" + +# Create service +cat > /etc/systemd/system/depl0y-backend.service << 'EOF' +[Unit] +Description=Depl0y Backend +After=network.target + +[Service] +Type=simple +User=depl0y +Group=depl0y +WorkingDirectory=/opt/depl0y/backend +EnvironmentFile=/etc/depl0y/config.env +ExecStart=/opt/depl0y/backend/venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8000 +Restart=always + +[Install] +WantedBy=multi-user.target +EOF + +# Configure nginx +cat > /etc/nginx/sites-available/depl0y << 'EOF' +server { + listen 80 default_server; + server_name _; + + client_max_body_size 10G; + + location / { + root /opt/depl0y/frontend/dist; + try_files $uri $uri/ /index.html; + } + + location /api { + proxy_pass http://127.0.0.1:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_http_version 1.1; + proxy_connect_timeout 300s; + proxy_send_timeout 300s; + proxy_read_timeout 300s; + } + + location /health { + proxy_pass http://127.0.0.1:8000/health; + access_log off; + } +} +EOF + +ln -sf /etc/nginx/sites-available/depl0y /etc/nginx/sites-enabled/ +rm -f /etc/nginx/sites-enabled/default +nginx -t + +# Start everything +echo "Starting services..." +systemctl daemon-reload +systemctl enable depl0y-backend +systemctl restart depl0y-backend +systemctl reload nginx + +sleep 3 + +SERVER_IP=$(hostname -I | awk '{print $1}') + +echo "" +echo "=========================================" +echo " ✓ Depl0y is Running!" +echo "=========================================" +echo "" +echo "URL: http://${SERVER_IP}" +echo "" +echo "Login:" +echo " Username: admin" +echo " Password: Admin123!" +echo "" +echo "Commands:" +echo " sudo systemctl status depl0y-backend" +echo " sudo systemctl restart depl0y-backend" +echo " sudo journalctl -u depl0y-backend -f" +echo "" +echo "=========================================" diff --git a/scripts/quickstart.sh b/scripts/quickstart.sh new file mode 100644 index 0000000..20e944f --- /dev/null +++ b/scripts/quickstart.sh @@ -0,0 +1,236 @@ +#!/bin/bash + +# Depl0y Quick Start (assumes MariaDB already installed) + +set -e + +echo "=========================================" +echo " Depl0y Quick Start" +echo "=========================================" +echo "" + +# Check if running as root +if [ "$EUID" -ne 0 ]; then + echo "Please run as root (use sudo)" + exit 1 +fi + +PROJECT_DIR="/home/administrator/depl0y" +cd "$PROJECT_DIR" + +# Setup MariaDB database +echo "Setting up database..." +mysql -u root << 'EOF' +CREATE DATABASE IF NOT EXISTS depl0y; +CREATE USER IF NOT EXISTS 'depl0y'@'localhost' IDENTIFIED BY 'depl0y_password_123'; +GRANT ALL PRIVILEGES ON depl0y.* TO 'depl0y'@'localhost'; +FLUSH PRIVILEGES; +EOF + +echo "Database created" + +# Create application user and directories +echo "Creating directories..." +useradd -r -s /bin/bash -d /opt/depl0y -m depl0y 2>/dev/null || true +mkdir -p /opt/depl0y/{backend,frontend} +mkdir -p /var/lib/depl0y/{isos,cloud-init,ssh_keys} +mkdir -p /var/log/depl0y +mkdir -p /etc/depl0y + +# Copy files +echo "Copying application files..." +cp -r "$PROJECT_DIR/backend"/* /opt/depl0y/backend/ +cp -r "$PROJECT_DIR/frontend"/* /opt/depl0y/frontend/ + +# Set permissions +chown -R depl0y:depl0y /opt/depl0y +chown -R depl0y:depl0y /var/lib/depl0y +chown -R depl0y:depl0y /var/log/depl0y + +# Generate secrets +echo "Generating configuration..." +SECRET_KEY=$(openssl rand -hex 32) +ENCRYPTION_KEY=$(python3 -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())") + +# Create config file +cat > /etc/depl0y/config.env << EOF +DATABASE_URL=mysql+pymysql://depl0y:depl0y_password_123@localhost:3306/depl0y +SECRET_KEY=${SECRET_KEY} +ENCRYPTION_KEY=${ENCRYPTION_KEY} +DEBUG=false +LOG_LEVEL=INFO +LOG_FILE=/var/log/depl0y/app.log +ISO_STORAGE_PATH=/var/lib/depl0y/isos +CLOUDINIT_TEMPLATE_PATH=/var/lib/depl0y/cloud-init +SSH_KEY_PATH=/var/lib/depl0y/ssh_keys +API_V1_PREFIX=/api/v1 +EOF + +chmod 600 /etc/depl0y/config.env +chown depl0y:depl0y /etc/depl0y/config.env + +# Install Python dependencies +echo "Installing Python dependencies (this may take a few minutes)..." +cd /opt/depl0y/backend +sudo -u depl0y python3 -m venv venv +sudo -u depl0y /opt/depl0y/backend/venv/bin/pip install --upgrade pip --quiet +sudo -u depl0y /opt/depl0y/backend/venv/bin/pip install -r requirements.txt --quiet + +echo "Python dependencies installed" + +# Initialize database +echo "Initializing database..." +cd /opt/depl0y/backend +sudo -u depl0y bash -c "source venv/bin/activate && source /etc/depl0y/config.env && python3 -c 'from app.core.database import init_db; init_db()'" + +echo "Database initialized" + +# Build frontend +echo "Building frontend (this may take a few minutes)..." +cd /opt/depl0y/frontend +sudo -u depl0y npm install --quiet +sudo -u depl0y npm run build + +echo "Frontend built" + +# Create systemd service +echo "Creating systemd service..." +cat > /etc/systemd/system/depl0y-backend.service << 'SVCEOF' +[Unit] +Description=Depl0y Backend API +After=network.target mariadb.service +Wants=mariadb.service + +[Service] +Type=simple +User=depl0y +Group=depl0y +WorkingDirectory=/opt/depl0y/backend +EnvironmentFile=/etc/depl0y/config.env +ExecStart=/opt/depl0y/backend/venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8000 +Restart=always +RestartSec=10 + +[Install] +WantedBy=multi-user.target +SVCEOF + +# Configure Nginx +echo "Configuring Nginx..." +cat > /etc/nginx/sites-available/depl0y << 'NGEOF' +server { + listen 80; + server_name _; + + client_max_body_size 10G; + + location / { + root /opt/depl0y/frontend/dist; + try_files $uri $uri/ /index.html; + + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + } + + location /api { + proxy_pass http://127.0.0.1:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_connect_timeout 300s; + proxy_send_timeout 300s; + proxy_read_timeout 300s; + } + + location /health { + proxy_pass http://127.0.0.1:8000/health; + access_log off; + } + + access_log /var/log/nginx/depl0y_access.log; + error_log /var/log/nginx/depl0y_error.log; +} +NGEOF + +ln -sf /etc/nginx/sites-available/depl0y /etc/nginx/sites-enabled/ +rm -f /etc/nginx/sites-enabled/default + +# Test nginx +nginx -t + +# Create admin user +echo "" +echo "Creating admin user..." +cd /opt/depl0y/backend +sudo -u depl0y bash -c "source venv/bin/activate && source /etc/depl0y/config.env && python3" << 'PYEOF' +from app.core.database import SessionLocal +from app.models import User, UserRole +from app.core.security import get_password_hash + +db = SessionLocal() +try: + # Delete existing admin if exists + existing = db.query(User).filter(User.username == "admin").first() + if existing: + db.delete(existing) + db.commit() + + # Create new admin + admin = User( + username="admin", + email="admin@depl0y.local", + hashed_password=get_password_hash("Admin123!"), + role=UserRole.ADMIN, + is_active=True + ) + db.add(admin) + db.commit() + print("✓ Admin user created") +except Exception as e: + print(f"Error: {e}") + import traceback + traceback.print_exc() +finally: + db.close() +PYEOF + +# Start services +echo "" +echo "Starting services..." +systemctl daemon-reload +systemctl enable depl0y-backend +systemctl restart depl0y-backend +systemctl reload nginx + +# Wait for startup +echo "Waiting for services to start..." +sleep 5 + +# Get server IP +SERVER_IP=$(hostname -I | awk '{print $1}') + +echo "" +echo "=========================================" +echo " Installation Complete!" +echo "=========================================" +echo "" +echo "✓ Depl0y is now running!" +echo "" +echo "Access: http://${SERVER_IP}" +echo "" +echo "Login:" +echo " Username: admin" +echo " Password: Admin123!" +echo "" +echo "Commands:" +echo " Status: sudo systemctl status depl0y-backend" +echo " Restart: sudo systemctl restart depl0y-backend" +echo " Logs: sudo journalctl -u depl0y-backend -f" +echo "" +echo "=========================================" diff --git a/scripts/setup.sh b/scripts/setup.sh new file mode 100644 index 0000000..4b0b5d7 --- /dev/null +++ b/scripts/setup.sh @@ -0,0 +1,175 @@ +#!/bin/bash + +# Depl0y Setup Script +# This script sets up Depl0y on a fresh server + +set -e + +echo "=========================================" +echo " Depl0y Installation Script" +echo "=========================================" +echo "" + +# Check if running as root +if [ "$EUID" -ne 0 ]; then + echo "Please run as root (use sudo)" + exit 1 +fi + +# Detect OS +if [ -f /etc/os-release ]; then + . /etc/os-release + OS=$ID + VERSION=$VERSION_ID +else + echo "Cannot detect OS. Exiting." + exit 1 +fi + +echo "Detected OS: $OS $VERSION" +echo "" + +# Install Docker and Docker Compose +echo "Installing Docker and Docker Compose..." +if ! command -v docker &> /dev/null; then + curl -fsSL https://get.docker.com | sh + systemctl enable docker + systemctl start docker + echo "Docker installed successfully" +else + echo "Docker is already installed" +fi + +if ! command -v docker-compose &> /dev/null; then + curl -L "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose + chmod +x /usr/local/bin/docker-compose + echo "Docker Compose installed successfully" +else + echo "Docker Compose is already installed" +fi + +echo "" + +# Generate secrets +echo "Generating secure secrets..." + +if [ ! -f .env ]; then + cp .env.example .env + + # Generate SECRET_KEY (64 character random string) + SECRET_KEY=$(openssl rand -hex 32) + sed -i "s|SECRET_KEY=.*|SECRET_KEY=$SECRET_KEY|" .env + + # Generate ENCRYPTION_KEY (Fernet key) + ENCRYPTION_KEY=$(python3 -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" 2>/dev/null || openssl rand -base64 32) + sed -i "s|ENCRYPTION_KEY=.*|ENCRYPTION_KEY=$ENCRYPTION_KEY|" .env + + # Generate database passwords + MYSQL_ROOT_PASSWORD=$(openssl rand -hex 16) + MYSQL_PASSWORD=$(openssl rand -hex 16) + sed -i "s|MYSQL_ROOT_PASSWORD=.*|MYSQL_ROOT_PASSWORD=$MYSQL_ROOT_PASSWORD|" .env + sed -i "s|MYSQL_PASSWORD=.*|MYSQL_PASSWORD=$MYSQL_PASSWORD|" .env + + echo ".env file created with secure random secrets" +else + echo ".env file already exists, skipping secret generation" +fi + +echo "" + +# Create necessary directories +echo "Creating directories..." +mkdir -p /var/lib/depl0y/{isos,cloud-init,ssh_keys} +mkdir -p /var/log/depl0y +chmod 755 /var/lib/depl0y +chmod 755 /var/log/depl0y + +echo "" + +# Build and start containers +echo "Building and starting Docker containers..." +docker-compose up -d --build + +echo "" +echo "Waiting for services to start..." +sleep 10 + +# Run database migrations +echo "Running database migrations..." +docker-compose exec -T backend alembic upgrade head || echo "Note: Alembic migrations may need to be configured" + +# Create default admin user +echo "" +echo "Creating default admin user..." +read -p "Enter admin username (default: admin): " ADMIN_USER +ADMIN_USER=${ADMIN_USER:-admin} + +read -p "Enter admin email: " ADMIN_EMAIL +while [ -z "$ADMIN_EMAIL" ]; do + echo "Email cannot be empty" + read -p "Enter admin email: " ADMIN_EMAIL +done + +read -sp "Enter admin password: " ADMIN_PASSWORD +echo "" +while [ ${#ADMIN_PASSWORD} -lt 8 ]; do + echo "Password must be at least 8 characters" + read -sp "Enter admin password: " ADMIN_PASSWORD + echo "" +done + +# Create admin user via Python script +docker-compose exec -T backend python3 << EOF +from app.core.database import SessionLocal +from app.models import User, UserRole +from app.core.security import get_password_hash + +db = SessionLocal() +try: + # Check if user exists + existing_user = db.query(User).filter(User.username == "$ADMIN_USER").first() + if existing_user: + print("User '$ADMIN_USER' already exists") + else: + # Create admin user + admin = User( + username="$ADMIN_USER", + email="$ADMIN_EMAIL", + hashed_password=get_password_hash("$ADMIN_PASSWORD"), + role=UserRole.ADMIN, + is_active=True + ) + db.add(admin) + db.commit() + print("Admin user created successfully") +except Exception as e: + print(f"Error creating admin user: {e}") +finally: + db.close() +EOF + +echo "" +echo "=========================================" +echo " Installation Complete!" +echo "=========================================" +echo "" +echo "Depl0y is now running!" +echo "" +echo "Access the web interface at: http://$(hostname -I | awk '{print $1}')" +echo "" +echo "Default credentials:" +echo " Username: $ADMIN_USER" +echo " Password: (the one you just entered)" +echo "" +echo "Important: Change the default admin password after first login!" +echo "" +echo "To view logs:" +echo " docker-compose logs -f" +echo "" +echo "To stop Depl0y:" +echo " docker-compose down" +echo "" +echo "To start Depl0y:" +echo " docker-compose up -d" +echo "" +echo "=========================================" diff --git a/scripts/uninstall.sh b/scripts/uninstall.sh new file mode 100644 index 0000000..7e412f4 --- /dev/null +++ b/scripts/uninstall.sh @@ -0,0 +1,203 @@ +#!/bin/bash +# +# Depl0y Uninstaller +# Completely removes Depl0y from your system +# + +set -e + +echo "╔══════════════════════════════════════════════════════════╗" +echo "║ ║" +echo "║ ██████╗ ███████╗██████╗ ██╗ ██████╗ ██╗ ██╗ ║" +echo "║ ██╔══██╗██╔════╝██╔══██╗██║ ██╔═████╗╚██╗ ██╔╝ ║" +echo "║ ██║ ██║█████╗ ██████╔╝██║ ██║██╔██║ ╚████╔╝ ║" +echo "║ ██║ ██║██╔══╝ ██╔═══╝ ██║ ████╔╝██║ ╚██╔╝ ║" +echo "║ ██████╔╝███████╗██║ ███████╗╚██████╔╝ ██║ ║" +echo "║ ╚═════╝ ╚══════╝╚═╝ ╚══════╝ ╚═════╝ ╚═╝ ║" +echo "║ ║" +echo "║ UNINSTALLER ║" +echo "║ ║" +echo "╚══════════════════════════════════════════════════════════╝" +echo "" + +# Check if running as root +if [ "$EUID" -ne 0 ]; then + echo "ERROR: Please run as root (use sudo)" + exit 1 +fi + +# Check for -y flag or if stdin is not a terminal (piped input) +SKIP_CONFIRM=false +if [[ "$1" == "-y" || "$1" == "--yes" ]]; then + SKIP_CONFIRM=true +elif [ ! -t 0 ]; then + # Not running in terminal (piped through curl) + echo "⚠️ WARNING: Running in non-interactive mode" + echo "" + echo "To uninstall Depl0y, run this command:" + echo " curl -fsSL http://deploy.agit8or.net/downloads/uninstall.sh | sudo bash -s -- -y" + echo "" + echo "Or download and run locally:" + echo " curl -fsSL http://deploy.agit8or.net/downloads/uninstall.sh -o uninstall.sh" + echo " sudo bash uninstall.sh" + echo "" + exit 1 +fi + +if [ "$SKIP_CONFIRM" = false ]; then + echo "⚠️ WARNING: This will completely remove Depl0y from your system" + echo "" + echo "This will:" + echo " • Stop and remove the depl0y-backend service" + echo " • Remove all Depl0y files from /opt/depl0y" + echo " • Remove database and logs from /var/lib/depl0y and /var/log/depl0y" + echo " • Remove nginx configuration" + echo " • Remove the depl0y system user" + echo " • Remove sudo permissions" + echo " • Remove installed dependencies (Python packages, Node.js packages)" + echo "" + echo "⚠️ DATABASE WILL BE DELETED - All VM configurations will be lost!" + echo "" + read -p "Are you sure you want to uninstall Depl0y? (yes/NO): " -r CONFIRM < /dev/tty || CONFIRM="NO" + echo "" + + if [ "$CONFIRM" != "yes" ]; then + echo "Uninstall cancelled." + exit 0 + fi +else + echo "⚠️ Uninstalling Depl0y (confirmation skipped with -y flag)..." + echo "" +fi + +echo "Starting uninstallation..." +echo "" + +# Stop and disable backend service +echo "🛑 Stopping depl0y-backend service..." +if systemctl is-active --quiet depl0y-backend; then + systemctl stop depl0y-backend + echo "✓ Service stopped" +else + echo "✓ Service was not running" +fi + +if systemctl is-enabled --quiet depl0y-backend 2>/dev/null; then + systemctl disable depl0y-backend + echo "✓ Service disabled" +fi + +# Remove systemd service file +echo "" +echo "🗑️ Removing systemd service..." +if [ -f /etc/systemd/system/depl0y-backend.service ]; then + rm -f /etc/systemd/system/depl0y-backend.service + systemctl daemon-reload + echo "✓ Service file removed" +fi + +# Remove application files +echo "" +echo "🗑️ Removing application files..." +if [ -d /opt/depl0y ]; then + rm -rf /opt/depl0y + echo "✓ Removed /opt/depl0y" +fi + +# Remove data and logs +echo "" +echo "🗑️ Removing database and logs..." +if [ -d /var/lib/depl0y ]; then + rm -rf /var/lib/depl0y + echo "✓ Removed /var/lib/depl0y (database, cloud images, ISOs)" +fi + +if [ -d /var/log/depl0y ]; then + rm -rf /var/log/depl0y + echo "✓ Removed /var/log/depl0y" +fi + +# Remove nginx configuration +echo "" +echo "🗑️ Removing nginx configuration..." +if [ -f /etc/nginx/sites-enabled/depl0y ]; then + rm -f /etc/nginx/sites-enabled/depl0y + echo "✓ Removed nginx sites-enabled/depl0y" +fi + +if [ -f /etc/nginx/sites-available/depl0y ]; then + rm -f /etc/nginx/sites-available/depl0y + echo "✓ Removed nginx sites-available/depl0y" +fi + +# Restore default site if it existed +if [ -f /etc/nginx/sites-available/default ] && [ ! -f /etc/nginx/sites-enabled/default ]; then + ln -s /etc/nginx/sites-available/default /etc/nginx/sites-enabled/default + echo "✓ Restored nginx default site" +fi + +# Test and reload nginx +if nginx -t >/dev/null 2>&1; then + systemctl reload nginx + echo "✓ Nginx reloaded" +else + echo "⚠️ Nginx configuration test failed, skipping reload" +fi + +# Remove sudoers file +echo "" +echo "🗑️ Removing sudo permissions..." +if [ -f /etc/sudoers.d/depl0y ]; then + rm -f /etc/sudoers.d/depl0y + echo "✓ Removed /etc/sudoers.d/depl0y" +fi + +# Remove depl0y user +echo "" +echo "👤 Removing depl0y system user..." +if id -u depl0y >/dev/null 2>&1; then + userdel -r depl0y 2>/dev/null || userdel depl0y 2>/dev/null || true + echo "✓ User 'depl0y' removed" +else + echo "✓ User 'depl0y' does not exist" +fi + +# Remove temporary files and caches +echo "" +echo "🧹 Cleaning up temporary files and caches..." +rm -f /tmp/depl0y-*.tar.gz 2>/dev/null || true +rm -rf /tmp/depl0y-install 2>/dev/null || true +rm -f /tmp/enable_cloud_images.sh 2>/dev/null || true +rm -rf /tmp/depl0y* 2>/dev/null || true +echo "✓ Temporary files cleaned" + +# Optional: Remove installed packages (commented out by default for safety) +echo "" +echo "📦 Package cleanup..." +echo " Note: System packages (Python, Node.js, nginx, etc.) were NOT removed" +echo " as they may be used by other applications." +echo "" +echo " To remove them manually if desired:" +echo " sudo apt-get remove --purge python3-venv python3-dev nodejs npm nginx" +echo " sudo apt-get autoremove" + +echo "" +echo "╔══════════════════════════════════════════════════════════╗" +echo "║ ║" +echo "║ ✅ UNINSTALL COMPLETE ║" +echo "║ ║" +echo "╚══════════════════════════════════════════════════════════╝" +echo "" +echo "Depl0y has been completely removed from your system." +echo "" +echo "The following were NOT automatically removed (remove manually if needed):" +echo " • System packages: python3, nodejs, npm, nginx, sqlite3" +echo " • (These may be used by other applications)" +echo "" +echo "To remove system packages manually:" +echo " sudo apt-get remove --purge python3-venv python3-dev nodejs npm nginx sqlite3" +echo " sudo apt-get autoremove" +echo "" +echo "To reinstall Depl0y:" +echo " curl -fsSL http://deploy.agit8or.net/downloads/install.sh | sudo bash" +echo "" diff --git a/scripts/update-wrapper.sh b/scripts/update-wrapper.sh new file mode 100644 index 0000000..6fba45c --- /dev/null +++ b/scripts/update-wrapper.sh @@ -0,0 +1,29 @@ +#!/bin/bash +# Depl0y Update Wrapper - Completely detaches installer from backend service +# This ensures the installer survives when it stops the backend + +INSTALLER_PATH="$1" + +if [ -z "$INSTALLER_PATH" ]; then + echo "ERROR: No installer path provided" + exit 1 +fi + +if [ ! -f "$INSTALLER_PATH" ]; then + echo "ERROR: Installer not found at $INSTALLER_PATH" + exit 1 +fi + +# Use 'at' to schedule the installer to run immediately but completely detached +# The 'at' daemon will run the job in its own process tree, independent of the backend service +# IMPORTANT: Must run with sudo since installer needs root permissions +echo "/usr/bin/sudo /bin/bash $INSTALLER_PATH > /tmp/depl0y-update.log 2>&1" | /usr/bin/at now 2>&1 + +if [ $? -eq 0 ]; then + echo "Update scheduled successfully via 'at' daemon" + echo "The installer will run independently and survive backend restarts" + exit 0 +else + echo "ERROR: Failed to schedule update" + exit 1 +fi diff --git a/src/backend/Dockerfile b/src/backend/Dockerfile new file mode 100644 index 0000000..54a1235 --- /dev/null +++ b/src/backend/Dockerfile @@ -0,0 +1,33 @@ +FROM python:3.11-slim + +# Set working directory +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + gcc \ + g++ \ + libmariadb-dev \ + pkg-config \ + && rm -rf /var/lib/apt/lists/* + +# Copy requirements file +COPY requirements.txt . + +# Install Python dependencies +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application code +COPY . . + +# Create necessary directories +RUN mkdir -p /app/storage/isos \ + /app/storage/cloud-init \ + /app/storage/ssh_keys \ + /app/logs + +# Expose port +EXPOSE 8000 + +# Run the application +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/src/backend/app/__init__.py b/src/backend/app/__init__.py new file mode 100644 index 0000000..a92cbbb --- /dev/null +++ b/src/backend/app/__init__.py @@ -0,0 +1 @@ +"""Main application package""" diff --git a/src/backend/app/api/__init__.py b/src/backend/app/api/__init__.py new file mode 100644 index 0000000..6423991 --- /dev/null +++ b/src/backend/app/api/__init__.py @@ -0,0 +1 @@ +"""API routes package""" diff --git a/src/backend/app/api/auth.py b/src/backend/app/api/auth.py new file mode 100644 index 0000000..e913f21 --- /dev/null +++ b/src/backend/app/api/auth.py @@ -0,0 +1,256 @@ +"""Authentication API routes""" +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm +from sqlalchemy.orm import Session +from pydantic import BaseModel, EmailStr +from typing import Optional +from datetime import datetime +import qrcode +import io +import base64 + +from app.core.database import get_db +from app.core.security import ( + verify_password, + get_password_hash, + create_access_token, + create_refresh_token, + decode_token, + generate_totp_secret, + generate_totp_uri, + verify_totp_code, +) +from app.models import User, UserRole + +router = APIRouter() +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/v1/auth/login") + + +# Pydantic models +class Token(BaseModel): + access_token: str + refresh_token: str + token_type: str + + +class LoginRequest(BaseModel): + username: str + password: str + totp_code: Optional[str] = None + + +class TOTPSetupResponse(BaseModel): + secret: str + qr_code: str + uri: str + + +class TOTPVerifyRequest(BaseModel): + code: str + + +class UserResponse(BaseModel): + id: int + username: str + email: str + role: UserRole + is_active: bool + totp_enabled: bool + created_at: datetime + + class Config: + from_attributes = True + + +# Dependency to get current user +async def get_current_user( + token: str = Depends(oauth2_scheme), db: Session = Depends(get_db) +) -> User: + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + + payload = decode_token(token) + if payload is None: + raise credentials_exception + + username: str = payload.get("sub") + if username is None: + raise credentials_exception + + user = db.query(User).filter(User.username == username).first() + if user is None: + raise credentials_exception + + if not user.is_active: + raise HTTPException(status_code=400, detail="Inactive user") + + return user + + +# Dependency for admin-only access +async def require_admin(current_user: User = Depends(get_current_user)) -> User: + if current_user.role != UserRole.ADMIN: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Admin privileges required", + ) + return current_user + + +# Dependency for operator or admin access +async def require_operator(current_user: User = Depends(get_current_user)) -> User: + if current_user.role not in [UserRole.ADMIN, UserRole.OPERATOR]: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Operator privileges required", + ) + return current_user + + +@router.post("/login", response_model=Token) +async def login(credentials: LoginRequest, db: Session = Depends(get_db)): + """Login endpoint""" + user = db.query(User).filter(User.username == credentials.username).first() + + if not user or not verify_password(credentials.password, user.hashed_password): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect username or password", + ) + + if not user.is_active: + raise HTTPException(status_code=400, detail="Inactive user") + + # Check 2FA if enabled + if user.totp_enabled: + if not credentials.totp_code: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="2FA code required", + ) + + if not verify_totp_code(user.totp_secret, credentials.totp_code): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid 2FA code", + ) + + # Update last login + user.last_login = datetime.utcnow() + db.commit() + + # Create tokens + access_token = create_access_token(data={"sub": user.username}) + refresh_token = create_refresh_token(data={"sub": user.username}) + + return { + "access_token": access_token, + "refresh_token": refresh_token, + "token_type": "bearer", + } + + +@router.post("/refresh", response_model=Token) +async def refresh_token(refresh_token: str, db: Session = Depends(get_db)): + """Refresh access token""" + payload = decode_token(refresh_token) + + if payload is None or payload.get("type") != "refresh": + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid refresh token", + ) + + username = payload.get("sub") + user = db.query(User).filter(User.username == username).first() + + if not user or not user.is_active: + raise HTTPException(status_code=401, detail="User not found or inactive") + + # Create new tokens + access_token = create_access_token(data={"sub": user.username}) + new_refresh_token = create_refresh_token(data={"sub": user.username}) + + return { + "access_token": access_token, + "refresh_token": new_refresh_token, + "token_type": "bearer", + } + + +@router.get("/me", response_model=UserResponse) +async def get_current_user_info(current_user: User = Depends(get_current_user)): + """Get current user information""" + return current_user + + +@router.post("/totp/setup", response_model=TOTPSetupResponse) +async def setup_totp(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): + """Setup TOTP 2FA for current user""" + # Generate new secret + secret = generate_totp_secret() + uri = generate_totp_uri(secret, current_user.username) + + # Generate QR code + qr = qrcode.QRCode(version=1, box_size=10, border=5) + qr.add_data(uri) + qr.make(fit=True) + + img = qr.make_image(fill_color="black", back_color="white") + buffer = io.BytesIO() + img.save(buffer, format="PNG") + qr_code_base64 = base64.b64encode(buffer.getvalue()).decode() + + # Store secret temporarily (not enabled yet) + current_user.totp_secret = secret + db.commit() + + return { + "secret": secret, + "qr_code": f"data:image/png;base64,{qr_code_base64}", + "uri": uri, + } + + +@router.post("/totp/verify") +async def verify_totp( + request: TOTPVerifyRequest, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """Verify and enable TOTP 2FA""" + if not current_user.totp_secret: + raise HTTPException(status_code=400, detail="TOTP not set up") + + if not verify_totp_code(current_user.totp_secret, request.code): + raise HTTPException(status_code=400, detail="Invalid TOTP code") + + # Enable TOTP + current_user.totp_enabled = True + db.commit() + + return {"message": "2FA enabled successfully"} + + +@router.post("/totp/disable") +async def disable_totp( + request: TOTPVerifyRequest, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """Disable TOTP 2FA""" + if not current_user.totp_enabled: + raise HTTPException(status_code=400, detail="2FA not enabled") + + if not verify_totp_code(current_user.totp_secret, request.code): + raise HTTPException(status_code=400, detail="Invalid TOTP code") + + # Disable TOTP + current_user.totp_enabled = False + current_user.totp_secret = None + db.commit() + + return {"message": "2FA disabled successfully"} diff --git a/src/backend/app/api/bug_report.py b/src/backend/app/api/bug_report.py new file mode 100644 index 0000000..8d467a4 --- /dev/null +++ b/src/backend/app/api/bug_report.py @@ -0,0 +1,109 @@ +"""Bug Report API routes""" +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, EmailStr +from typing import Optional +import smtplib +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart +import logging +from datetime import datetime + +from app.api.auth import get_current_user +from app.models import User + +router = APIRouter() +logger = logging.getLogger(__name__) + + +class BugReport(BaseModel): + subject: str + description: str + page_url: Optional[str] = None + error_details: Optional[str] = None + browser_info: Optional[str] = None + + +@router.post("/") +async def submit_bug_report( + bug_report: BugReport, + current_user: User = Depends(get_current_user), +): + """Submit a bug report via email""" + try: + # Create email message + msg = MIMEMultipart('alternative') + msg['Subject'] = f"[Depl0y Bug Report] {bug_report.subject}" + msg['From'] = "agit8or@agit8or.net" + msg['To'] = "agit8or@agit8or.net" + + # Create email body + text_body = f""" +Bug Report from Depl0y + +Reported by: {current_user.username} ({current_user.email}) +Time: {datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S UTC')} +Page URL: {bug_report.page_url or 'N/A'} + +Description: +{bug_report.description} + +Error Details: +{bug_report.error_details or 'N/A'} + +Browser Info: +{bug_report.browser_info or 'N/A'} +""" + + html_body = f""" + + +

Bug Report from Depl0y

+ + + + + + + + + + + + + +
Reported by:{current_user.username} ({current_user.email})
Time:{datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S UTC')}
Page URL:{bug_report.page_url or 'N/A'}
+ +
+

Description:

+

{bug_report.description}

+
+ +
+

Error Details:

+
{bug_report.error_details or 'N/A'}
+
+ +
+

Browser Info:

+

{bug_report.browser_info or 'N/A'}

+
+ + +""" + + # Attach both text and HTML versions + part1 = MIMEText(text_body, 'plain') + part2 = MIMEText(html_body, 'html') + msg.attach(part1) + msg.attach(part2) + + # Send email using local sendmail + with smtplib.SMTP('localhost') as server: + server.send_message(msg) + + logger.info(f"Bug report sent from {current_user.username}: {bug_report.subject}") + return {"status": "success", "message": "Bug report submitted successfully"} + + except Exception as e: + logger.error(f"Failed to send bug report: {e}") + raise HTTPException(status_code=500, detail="Failed to send bug report. Please try again later.") diff --git a/src/backend/app/api/cloud_images.py b/src/backend/app/api/cloud_images.py new file mode 100644 index 0000000..11fc73a --- /dev/null +++ b/src/backend/app/api/cloud_images.py @@ -0,0 +1,565 @@ +"""Cloud Images API endpoints""" +from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks +from sqlalchemy.orm import Session +from typing import List +from pydantic import BaseModel +from app.core.database import get_db +from app.models import CloudImage +from app.api.auth import get_current_user +import logging +import os + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +class CloudImageResponse(BaseModel): + id: int + name: str + filename: str + os_type: str + version: str | None + architecture: str + file_size: int | None + download_url: str + is_downloaded: bool + download_progress: int + download_status: str + is_available: bool + + class Config: + from_attributes = True + + +@router.get("/", response_model=List[CloudImageResponse]) +def list_cloud_images( + db: Session = Depends(get_db), + current_user=Depends(get_current_user) +): + """List all available cloud images""" + try: + images = db.query(CloudImage).filter(CloudImage.is_available == True).all() + return images + except Exception as e: + logger.error(f"Failed to list cloud images: {e}") + raise HTTPException(status_code=500, detail=str(e)) + +@router.post("/fetch-latest") +def fetch_latest_cloud_images( + db: Session = Depends(get_db), + current_user=Depends(get_current_user) +): + """Fetch the latest cloud image information from upstream sources and update existing images""" + try: + import requests + import re + from datetime import datetime + + updated_images = [] + errors = [] + + # Get all existing cloud images + images = db.query(CloudImage).filter(CloudImage.is_available == True).all() + + for image in images: + try: + updated = False + old_version = image.version + old_url = image.download_url + + # Ubuntu cloud images + if image.os_type == 'ubuntu' and 'ubuntu' in image.download_url.lower(): + # Extract version from existing URL or use stored version + version_match = re.search(r'(\d+\.\d+)', image.download_url) + if version_match: + version = version_match.group(1) + + # Check for new URL format + base_url = f"https://cloud-images.ubuntu.com/releases/{version}/release/" + try: + response = requests.head(base_url + image.filename, timeout=10) + if response.status_code == 200: + new_url = base_url + image.filename + if new_url != image.download_url: + image.download_url = new_url + updated = True + except: + pass + + # Debian cloud images + elif image.os_type == 'debian' and 'debian' in image.download_url.lower(): + # Try to get latest release URL + try: + # Debian uses a "latest" symlink for current release + if 'latest' in image.download_url: + # URL is already using latest, just verify it's accessible + response = requests.head(image.download_url, timeout=10, allow_redirects=True) + if response.status_code == 200: + # Update version if we can determine it from redirect + if 'debian-' in response.url: + version_match = re.search(r'debian-(\d+)', response.url) + if version_match and version_match.group(1) != image.version: + image.version = version_match.group(1) + updated = True + except: + pass + + # Rocky Linux cloud images + elif image.os_type == 'rocky' and 'rocky' in image.download_url.lower(): + # Rocky Linux has versioned releases + version_match = re.search(r'rocky[/-](\d+)', image.download_url, re.IGNORECASE) + if version_match: + version = version_match.group(1) + # Check if URL is still valid + try: + response = requests.head(image.download_url, timeout=10) + if response.status_code != 200: + # Try to construct new URL + base_url = f"https://download.rockylinux.org/pub/rocky/{version}/images/x86_64/" + response = requests.head(base_url + image.filename, timeout=10) + if response.status_code == 200: + image.download_url = base_url + image.filename + updated = True + except: + pass + + if updated: + db.commit() + updated_images.append({ + "name": image.name, + "old_version": old_version, + "new_version": image.version, + "old_url": old_url, + "new_url": image.download_url + }) + logger.info(f"Updated cloud image: {image.name}") + + except Exception as e: + error_msg = f"Failed to update {image.name}: {str(e)}" + logger.error(error_msg) + errors.append(error_msg) + continue + + return { + "message": f"Checked {len(images)} cloud images", + "updated_count": len(updated_images), + "updated_images": updated_images, + "errors": errors if errors else None + } + + except Exception as e: + logger.error(f"Failed to fetch latest cloud images: {e}") + raise HTTPException(status_code=500, detail=str(e)) + +@router.post("/{image_id}/download") +def download_cloud_image( + image_id: int, + background_tasks: BackgroundTasks, + db: Session = Depends(get_db), + current_user=Depends(get_current_user) +): + """Trigger download of a cloud image""" + try: + image = db.query(CloudImage).filter(CloudImage.id == image_id).first() + if not image: + raise HTTPException(status_code=404, detail="Cloud image not found") + + if image.is_downloaded: + return {"message": "Cloud image already downloaded", "image": image} + + # Import here to avoid circular dependency + from app.services.cloud_images import download_cloud_image_task + + # Start download in background + background_tasks.add_task(download_cloud_image_task, image_id, db) + + return {"message": "Download started", "image_id": image_id} + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to start download: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/{image_id}/progress") +def get_download_progress( + image_id: int, + db: Session = Depends(get_db), + current_user=Depends(get_current_user) +): + """Get download progress for a cloud image""" + try: + image = db.query(CloudImage).filter(CloudImage.id == image_id).first() + if not image: + raise HTTPException(status_code=404, detail="Cloud image not found") + + return { + "id": image.id, + "name": image.name, + "download_progress": image.download_progress, + "download_status": image.download_status, + "is_downloaded": image.is_downloaded + } + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to get download progress: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +class CloudImageCreate(BaseModel): + name: str + filename: str + os_type: str + version: str | None = None + architecture: str = "amd64" + checksum: str | None = None + download_url: str + + +class CloudImageUpdate(BaseModel): + name: str | None = None + filename: str | None = None + os_type: str | None = None + version: str | None = None + architecture: str | None = None + checksum: str | None = None + download_url: str | None = None + is_available: bool | None = None + + +@router.post("/", response_model=CloudImageResponse) +def create_cloud_image( + image_data: CloudImageCreate, + db: Session = Depends(get_db), + current_user=Depends(get_current_user) +): + """Create a new cloud image entry""" + try: + # Check if filename already exists + existing = db.query(CloudImage).filter(CloudImage.filename == image_data.filename).first() + if existing: + raise HTTPException(status_code=400, detail="Cloud image with this filename already exists") + + new_image = CloudImage( + name=image_data.name, + filename=image_data.filename, + os_type=image_data.os_type, + version=image_data.version, + architecture=image_data.architecture, + checksum=image_data.checksum, + download_url=image_data.download_url, + download_status="pending", + is_downloaded=False + ) + + db.add(new_image) + db.commit() + db.refresh(new_image) + + logger.info(f"Created new cloud image: {new_image.name}") + return new_image + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to create cloud image: {e}") + db.rollback() + raise HTTPException(status_code=500, detail=str(e)) + + +@router.put("/{image_id}", response_model=CloudImageResponse) +def update_cloud_image( + image_id: int, + image_data: CloudImageUpdate, + db: Session = Depends(get_db), + current_user=Depends(get_current_user) +): + """Update a cloud image entry""" + try: + image = db.query(CloudImage).filter(CloudImage.id == image_id).first() + if not image: + raise HTTPException(status_code=404, detail="Cloud image not found") + + # Update fields if provided + if image_data.name is not None: + image.name = image_data.name + if image_data.filename is not None: + image.filename = image_data.filename + if image_data.os_type is not None: + image.os_type = image_data.os_type + if image_data.version is not None: + image.version = image_data.version + if image_data.architecture is not None: + image.architecture = image_data.architecture + if image_data.checksum is not None: + image.checksum = image_data.checksum + if image_data.download_url is not None: + image.download_url = image_data.download_url + if image_data.is_available is not None: + image.is_available = image_data.is_available + + db.commit() + db.refresh(image) + + logger.info(f"Updated cloud image: {image.name}") + return image + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to update cloud image: {e}") + db.rollback() + raise HTTPException(status_code=500, detail=str(e)) + + +@router.delete("/{image_id}") +def delete_cloud_image( + image_id: int, + db: Session = Depends(get_db), + current_user=Depends(get_current_user) +): + """Delete a cloud image""" + try: + image = db.query(CloudImage).filter(CloudImage.id == image_id).first() + if not image: + raise HTTPException(status_code=404, detail="Cloud image not found") + + # Delete the file if it exists + if image.storage_path and os.path.exists(image.storage_path): + try: + os.remove(image.storage_path) + logger.info(f"Deleted cloud image file: {image.storage_path}") + except Exception as e: + logger.warning(f"Failed to delete cloud image file: {e}") + + db.delete(image) + db.commit() + + logger.info(f"Deleted cloud image: {image.name}") + return {"message": "Cloud image deleted successfully"} + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to delete cloud image: {e}") + db.rollback() + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/setup-script") +def get_template_setup_script( + db: Session = Depends(get_db), + current_user=Depends(get_current_user) +): + """Get the shell script to setup cloud image templates on Proxmox""" + # Get first 3 cloud images for the script + images = db.query(CloudImage).filter(CloudImage.is_available == True).limit(3).all() + + script_lines = [ + "#!/bin/bash", + "# Cloud Image Template Setup Script for Proxmox", + "# Run this script on your Proxmox node as root", + "", + "set -e", + "", + "echo '============================================'", + "echo 'Setting up cloud image templates...'", + "echo '============================================'", + "", + ] + + for idx, image in enumerate(images): + template_id = 9000 + idx + script_lines.extend([ + f"# {image.name}", + f"if ! qm status {template_id} &>/dev/null; then", + f" echo 'Creating template {template_id}: {image.name}'", + f" wget -q -O /var/lib/vz/template/iso/{image.filename} '{image.download_url}'", + f" qm create {template_id} --name '{image.name.lower().replace(' ', '-')}' --memory 2048 --cores 2 --net0 virtio,bridge=vmbr0", + f" qm importdisk {template_id} /var/lib/vz/template/iso/{image.filename} local-lvm", + f" qm set {template_id} --scsihw virtio-scsi-pci --scsi0 local-lvm:vm-{template_id}-disk-0", + f" qm set {template_id} --ide2 local-lvm:cloudinit", + f" qm set {template_id} --boot order=scsi0", + f" qm set {template_id} --serial0 socket --vga serial0", + f" qm set {template_id} --agent enabled=1", + f" qm template {template_id}", + f" echo '✓ Template {template_id} created'", + "else", + f" echo 'Template {template_id} already exists'", + "fi", + "", + ]) + + script_lines.extend([ + "echo '============================================'", + "echo 'Template setup complete!'", + "echo 'You can now use cloud images in Depl0y'", + "echo '============================================'", + ]) + + return { + "script": "\n".join(script_lines) + } + + +@router.get("/templates/status/{node_id}") +def check_templates_on_node( + node_id: int, + db: Session = Depends(get_db), + current_user=Depends(get_current_user) +): + """Check which cloud image templates exist on a Proxmox node""" + try: + from app.models import ProxmoxNode, ProxmoxHost + from app.services.proxmox import ProxmoxService + + node = db.query(ProxmoxNode).filter(ProxmoxNode.id == node_id).first() + if not node: + raise HTTPException(status_code=404, detail="Node not found") + + host = db.query(ProxmoxHost).filter(ProxmoxHost.id == node.host_id).first() + if not host: + raise HTTPException(status_code=404, detail="Proxmox host not found") + + proxmox = ProxmoxService(host) + + # Check templates 9000-9010 + template_status = {} + for i in range(9000, 9011): + try: + proxmox.proxmox.nodes(node.node_name).qemu(i).status.current.get() + template_status[i] = True + except: + template_status[i] = False + + return { + "node_id": node_id, + "node_name": node.node_name, + "templates": template_status + } + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to check templates: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +class SetupTemplatesRequest(BaseModel): + node_id: int + cloud_image_ids: List[int] + + +@router.post("/setup-templates") +def setup_templates_automated( + request: SetupTemplatesRequest, + background_tasks: BackgroundTasks, + db: Session = Depends(get_db), + current_user=Depends(get_current_user) +): + """Automatically setup cloud image templates on a Proxmox node""" + try: + from app.models import ProxmoxNode, ProxmoxHost + from app.services.cloud_images import create_template_on_node + + node = db.query(ProxmoxNode).filter(ProxmoxNode.id == request.node_id).first() + if not node: + raise HTTPException(status_code=404, detail="Node not found") + + host = db.query(ProxmoxHost).filter(ProxmoxHost.id == node.host_id).first() + if not host: + raise HTTPException(status_code=404, detail="Proxmox host not found") + + # Validate all cloud images exist + for image_id in request.cloud_image_ids: + image = db.query(CloudImage).filter(CloudImage.id == image_id).first() + if not image: + raise HTTPException(status_code=404, detail=f"Cloud image {image_id} not found") + + # Start template creation in background + for image_id in request.cloud_image_ids: + template_vmid = 9000 + (image_id - 1) + background_tasks.add_task( + create_template_on_node, + image_id, + request.node_id, + template_vmid, + db + ) + + return { + "message": f"Template setup started for {len(request.cloud_image_ids)} cloud images on node {node.node_name}", + "node_id": request.node_id, + "node_name": node.node_name, + "cloud_image_ids": request.cloud_image_ids + } + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to start template setup: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/ssh-status") +def check_ssh_status( + db: Session = Depends(get_db), + current_user=Depends(get_current_user) +): + """Check if SSH access is configured for Proxmox hosts""" + try: + import subprocess + from app.models import ProxmoxHost + + # Get the first Proxmox host + host = db.query(ProxmoxHost).first() + if not host: + return { + "configured": False, + "message": "No Proxmox host configured" + } + + # Test SSH connection + try: + # Try to run a simple command via SSH + result = subprocess.run( + [ + 'ssh', + '-o', 'BatchMode=yes', + '-o', 'ConnectTimeout=5', + '-o', 'StrictHostKeyChecking=no', + f'root@{host.hostname}', + 'echo SSH_KEY_CONFIGURED' + ], + capture_output=True, + text=True, + timeout=10 + ) + + if result.returncode == 0 and 'SSH_KEY_CONFIGURED' in result.stdout: + return { + "configured": True, + "message": "SSH access is configured", + "host": host.hostname + } + else: + return { + "configured": False, + "message": "SSH access not configured. Please run the setup script.", + "host": host.hostname + } + except subprocess.TimeoutExpired: + return { + "configured": False, + "message": "SSH connection timed out", + "host": host.hostname + } + except Exception as ssh_error: + logger.warning(f"SSH check failed: {ssh_error}") + return { + "configured": False, + "message": "SSH access not configured", + "host": host.hostname + } + except Exception as e: + logger.error(f"Failed to check SSH status: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + diff --git a/src/backend/app/api/cloud_images.py.backup b/src/backend/app/api/cloud_images.py.backup new file mode 100644 index 0000000..41ffe71 --- /dev/null +++ b/src/backend/app/api/cloud_images.py.backup @@ -0,0 +1,457 @@ +"""Cloud Images API endpoints""" +from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks +from sqlalchemy.orm import Session +from typing import List +from pydantic import BaseModel +from app.core.database import get_db +from app.models import CloudImage +from app.api.auth import get_current_user +import logging +import os + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +class CloudImageResponse(BaseModel): + id: int + name: str + filename: str + os_type: str + version: str | None + architecture: str + file_size: int | None + download_url: str + is_downloaded: bool + download_progress: int + download_status: str + is_available: bool + + class Config: + from_attributes = True + + +@router.get("/", response_model=List[CloudImageResponse]) +def list_cloud_images( + db: Session = Depends(get_db), + current_user=Depends(get_current_user) +): + """List all available cloud images""" + try: + images = db.query(CloudImage).filter(CloudImage.is_available == True).all() + return images + except Exception as e: + logger.error(f"Failed to list cloud images: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/{image_id}/download") +def download_cloud_image( + image_id: int, + background_tasks: BackgroundTasks, + db: Session = Depends(get_db), + current_user=Depends(get_current_user) +): + """Trigger download of a cloud image""" + try: + image = db.query(CloudImage).filter(CloudImage.id == image_id).first() + if not image: + raise HTTPException(status_code=404, detail="Cloud image not found") + + if image.is_downloaded: + return {"message": "Cloud image already downloaded", "image": image} + + # Import here to avoid circular dependency + from app.services.cloud_images import download_cloud_image_task + + # Start download in background + background_tasks.add_task(download_cloud_image_task, image_id, db) + + return {"message": "Download started", "image_id": image_id} + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to start download: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/{image_id}/progress") +def get_download_progress( + image_id: int, + db: Session = Depends(get_db), + current_user=Depends(get_current_user) +): + """Get download progress for a cloud image""" + try: + image = db.query(CloudImage).filter(CloudImage.id == image_id).first() + if not image: + raise HTTPException(status_code=404, detail="Cloud image not found") + + return { + "id": image.id, + "name": image.name, + "download_progress": image.download_progress, + "download_status": image.download_status, + "is_downloaded": image.is_downloaded + } + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to get download progress: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +class CloudImageCreate(BaseModel): + name: str + filename: str + os_type: str + version: str | None = None + architecture: str = "amd64" + checksum: str | None = None + download_url: str + + +class CloudImageUpdate(BaseModel): + name: str | None = None + filename: str | None = None + os_type: str | None = None + version: str | None = None + architecture: str | None = None + checksum: str | None = None + download_url: str | None = None + is_available: bool | None = None + + +@router.post("/", response_model=CloudImageResponse) +def create_cloud_image( + image_data: CloudImageCreate, + db: Session = Depends(get_db), + current_user=Depends(get_current_user) +): + """Create a new cloud image entry""" + try: + # Check if filename already exists + existing = db.query(CloudImage).filter(CloudImage.filename == image_data.filename).first() + if existing: + raise HTTPException(status_code=400, detail="Cloud image with this filename already exists") + + new_image = CloudImage( + name=image_data.name, + filename=image_data.filename, + os_type=image_data.os_type, + version=image_data.version, + architecture=image_data.architecture, + checksum=image_data.checksum, + download_url=image_data.download_url, + download_status="pending", + is_downloaded=False + ) + + db.add(new_image) + db.commit() + db.refresh(new_image) + + logger.info(f"Created new cloud image: {new_image.name}") + return new_image + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to create cloud image: {e}") + db.rollback() + raise HTTPException(status_code=500, detail=str(e)) + + +@router.put("/{image_id}", response_model=CloudImageResponse) +def update_cloud_image( + image_id: int, + image_data: CloudImageUpdate, + db: Session = Depends(get_db), + current_user=Depends(get_current_user) +): + """Update a cloud image entry""" + try: + image = db.query(CloudImage).filter(CloudImage.id == image_id).first() + if not image: + raise HTTPException(status_code=404, detail="Cloud image not found") + + # Update fields if provided + if image_data.name is not None: + image.name = image_data.name + if image_data.filename is not None: + image.filename = image_data.filename + if image_data.os_type is not None: + image.os_type = image_data.os_type + if image_data.version is not None: + image.version = image_data.version + if image_data.architecture is not None: + image.architecture = image_data.architecture + if image_data.checksum is not None: + image.checksum = image_data.checksum + if image_data.download_url is not None: + image.download_url = image_data.download_url + if image_data.is_available is not None: + image.is_available = image_data.is_available + + db.commit() + db.refresh(image) + + logger.info(f"Updated cloud image: {image.name}") + return image + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to update cloud image: {e}") + db.rollback() + raise HTTPException(status_code=500, detail=str(e)) + + +@router.delete("/{image_id}") +def delete_cloud_image( + image_id: int, + db: Session = Depends(get_db), + current_user=Depends(get_current_user) +): + """Delete a cloud image""" + try: + image = db.query(CloudImage).filter(CloudImage.id == image_id).first() + if not image: + raise HTTPException(status_code=404, detail="Cloud image not found") + + # Delete the file if it exists + if image.storage_path and os.path.exists(image.storage_path): + try: + os.remove(image.storage_path) + logger.info(f"Deleted cloud image file: {image.storage_path}") + except Exception as e: + logger.warning(f"Failed to delete cloud image file: {e}") + + db.delete(image) + db.commit() + + logger.info(f"Deleted cloud image: {image.name}") + return {"message": "Cloud image deleted successfully"} + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to delete cloud image: {e}") + db.rollback() + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/setup-script") +def get_template_setup_script( + db: Session = Depends(get_db), + current_user=Depends(get_current_user) +): + """Get the shell script to setup cloud image templates on Proxmox""" + # Get first 3 cloud images for the script + images = db.query(CloudImage).filter(CloudImage.is_available == True).limit(3).all() + + script_lines = [ + "#!/bin/bash", + "# Cloud Image Template Setup Script for Proxmox", + "# Run this script on your Proxmox node as root", + "", + "set -e", + "", + "echo '============================================'", + "echo 'Setting up cloud image templates...'", + "echo '============================================'", + "", + ] + + for idx, image in enumerate(images): + template_id = 9000 + idx + script_lines.extend([ + f"# {image.name}", + f"if ! qm status {template_id} &>/dev/null; then", + f" echo 'Creating template {template_id}: {image.name}'", + f" wget -q -O /var/lib/vz/template/iso/{image.filename} '{image.download_url}'", + f" qm create {template_id} --name '{image.name.lower().replace(' ', '-')}' --memory 2048 --cores 2 --net0 virtio,bridge=vmbr0", + f" qm importdisk {template_id} /var/lib/vz/template/iso/{image.filename} local-lvm", + f" qm set {template_id} --scsihw virtio-scsi-pci --scsi0 local-lvm:vm-{template_id}-disk-0", + f" qm set {template_id} --ide2 local-lvm:cloudinit", + f" qm set {template_id} --boot order=scsi0", + f" qm set {template_id} --serial0 socket --vga serial0", + f" qm set {template_id} --agent enabled=1", + f" qm template {template_id}", + f" echo '✓ Template {template_id} created'", + "else", + f" echo 'Template {template_id} already exists'", + "fi", + "", + ]) + + script_lines.extend([ + "echo '============================================'", + "echo 'Template setup complete!'", + "echo 'You can now use cloud images in Depl0y'", + "echo '============================================'", + ]) + + return { + "script": "\n".join(script_lines) + } + + +@router.get("/templates/status/{node_id}") +def check_templates_on_node( + node_id: int, + db: Session = Depends(get_db), + current_user=Depends(get_current_user) +): + """Check which cloud image templates exist on a Proxmox node""" + try: + from app.models import ProxmoxNode, ProxmoxHost + from app.services.proxmox import ProxmoxService + + node = db.query(ProxmoxNode).filter(ProxmoxNode.id == node_id).first() + if not node: + raise HTTPException(status_code=404, detail="Node not found") + + host = db.query(ProxmoxHost).filter(ProxmoxHost.id == node.host_id).first() + if not host: + raise HTTPException(status_code=404, detail="Proxmox host not found") + + proxmox = ProxmoxService(host) + + # Check templates 9000-9010 + template_status = {} + for i in range(9000, 9011): + try: + proxmox.proxmox.nodes(node.node_name).qemu(i).status.current.get() + template_status[i] = True + except: + template_status[i] = False + + return { + "node_id": node_id, + "node_name": node.node_name, + "templates": template_status + } + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to check templates: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +class SetupTemplatesRequest(BaseModel): + node_id: int + cloud_image_ids: List[int] + + +@router.post("/setup-templates") +def setup_templates_automated( + request: SetupTemplatesRequest, + background_tasks: BackgroundTasks, + db: Session = Depends(get_db), + current_user=Depends(get_current_user) +): + """Automatically setup cloud image templates on a Proxmox node""" + try: + from app.models import ProxmoxNode, ProxmoxHost + from app.services.cloud_images import create_template_on_node + + node = db.query(ProxmoxNode).filter(ProxmoxNode.id == request.node_id).first() + if not node: + raise HTTPException(status_code=404, detail="Node not found") + + host = db.query(ProxmoxHost).filter(ProxmoxHost.id == node.host_id).first() + if not host: + raise HTTPException(status_code=404, detail="Proxmox host not found") + + # Validate all cloud images exist + for image_id in request.cloud_image_ids: + image = db.query(CloudImage).filter(CloudImage.id == image_id).first() + if not image: + raise HTTPException(status_code=404, detail=f"Cloud image {image_id} not found") + + # Start template creation in background + for image_id in request.cloud_image_ids: + template_vmid = 9000 + (image_id - 1) + background_tasks.add_task( + create_template_on_node, + image_id, + request.node_id, + template_vmid, + db + ) + + return { + "message": f"Template setup started for {len(request.cloud_image_ids)} cloud images on node {node.node_name}", + "node_id": request.node_id, + "node_name": node.node_name, + "cloud_image_ids": request.cloud_image_ids + } + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to start template setup: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/ssh-status") +def check_ssh_status( + db: Session = Depends(get_db), + current_user=Depends(get_current_user) +): + """Check if SSH access is configured for Proxmox hosts""" + try: + import subprocess + from app.models import ProxmoxHost + + # Get the first Proxmox host + host = db.query(ProxmoxHost).first() + if not host: + return { + "configured": False, + "message": "No Proxmox host configured" + } + + # Test SSH connection + try: + # Try to run a simple command via SSH + result = subprocess.run( + [ + 'ssh', + '-o', 'BatchMode=yes', + '-o', 'ConnectTimeout=5', + '-o', 'StrictHostKeyChecking=no', + f'root@{host.hostname}', + 'echo SSH_KEY_CONFIGURED' + ], + capture_output=True, + text=True, + timeout=10 + ) + + if result.returncode == 0 and 'SSH_KEY_CONFIGURED' in result.stdout: + return { + "configured": True, + "message": "SSH access is configured", + "host": host.hostname + } + else: + return { + "configured": False, + "message": "SSH access not configured. Please run the setup script.", + "host": host.hostname + } + except subprocess.TimeoutExpired: + return { + "configured": False, + "message": "SSH connection timed out", + "host": host.hostname + } + except Exception as ssh_error: + logger.warning(f"SSH check failed: {ssh_error}") + return { + "configured": False, + "message": "SSH access not configured", + "host": host.hostname + } + except Exception as e: + logger.error(f"Failed to check SSH status: {e}") + raise HTTPException(status_code=500, detail=str(e)) diff --git a/src/backend/app/api/dashboard.py b/src/backend/app/api/dashboard.py new file mode 100644 index 0000000..77cbfd9 --- /dev/null +++ b/src/backend/app/api/dashboard.py @@ -0,0 +1,198 @@ +"""Dashboard API routes""" +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session +from sqlalchemy import func +from pydantic import BaseModel +import logging + +from app.core.database import get_db +from app.models import ( + VirtualMachine, + VMStatus, + ProxmoxHost, + ProxmoxNode, + ISOImage, + User, + UpdateLog, +) +from app.api.auth import get_current_user + +router = APIRouter() +logger = logging.getLogger(__name__) + + +# Pydantic models +class DashboardStats(BaseModel): + total_vms: int + running_vms: int + stopped_vms: int + paused_vms: int + datacenters: int + total_nodes: int + total_isos: int + total_users: int + + +class ResourceStats(BaseModel): + total_cpu_cores: int + total_memory_gb: float + total_disk_gb: float + used_cpu_cores: int + used_memory_gb: float + used_disk_gb: float + + +@router.get("/stats", response_model=DashboardStats) +async def get_dashboard_stats( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """Get dashboard statistics - queries actual Proxmox data""" + from app.services.proxmox import ProxmoxService + + # Query all active Proxmox hosts and get VM counts from Proxmox itself + active_hosts = db.query(ProxmoxHost).filter(ProxmoxHost.is_active == True).all() + + total_vms = 0 + running_vms = 0 + stopped_vms = 0 + paused_vms = 0 + + for host in active_hosts: + try: + service = ProxmoxService(host) + vms = service.get_all_vms() + total_vms += len(vms) + + for vm in vms: + status = vm.get('status', '').lower() + if status == 'running': + running_vms += 1 + elif status == 'stopped': + stopped_vms += 1 + elif status == 'paused': + paused_vms += 1 + except Exception as e: + logger.error(f"Failed to get VMs from host {host.name}: {e}") + + # Datacenter count (number of Proxmox hosts) + datacenters = len(active_hosts) + + # Node statistics + total_nodes = db.query(ProxmoxNode).count() + + # ISO statistics + total_isos = db.query(ISOImage).filter(ISOImage.is_available == True).count() + + # User statistics (admin only) + if current_user.role.value == "admin": + total_users = db.query(User).count() + else: + total_users = 0 + + return { + "total_vms": total_vms, + "running_vms": running_vms, + "stopped_vms": stopped_vms, + "paused_vms": paused_vms, + "datacenters": datacenters, + "total_nodes": total_nodes, + "total_isos": total_isos, + "total_users": total_users, + } + + +@router.get("/resources", response_model=ResourceStats) +async def get_resource_stats( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """Get resource statistics across all nodes""" + # Query node resources - these are ACTUAL usage from Proxmox + nodes = db.query(ProxmoxNode).all() + + total_cpu_cores = 0 + total_memory_bytes = 0 + total_disk_bytes = 0 + used_memory_bytes = 0 + used_disk_bytes = 0 + total_cpu_usage_percent = 0 + node_count = 0 + + for node in nodes: + if node.cpu_cores: + total_cpu_cores += node.cpu_cores + if node.memory_total: + total_memory_bytes += node.memory_total + if node.memory_used: + used_memory_bytes += node.memory_used + if node.disk_total: + total_disk_bytes += node.disk_total + if node.disk_used: + used_disk_bytes += node.disk_used + if node.cpu_usage is not None: + total_cpu_usage_percent += node.cpu_usage + node_count += 1 + + # Calculate used CPU cores based on average CPU usage across nodes + # This gives actual CPU usage, not just allocated cores + if node_count > 0 and total_cpu_cores > 0: + avg_cpu_usage_percent = total_cpu_usage_percent / node_count + used_cpu_cores = int((avg_cpu_usage_percent / 100.0) * total_cpu_cores) + else: + used_cpu_cores = 0 + + return { + "total_cpu_cores": total_cpu_cores, + "total_memory_gb": round(total_memory_bytes / (1024**3), 2), + "total_disk_gb": round(total_disk_bytes / (1024**3), 2), + "used_cpu_cores": used_cpu_cores, + "used_memory_gb": round(used_memory_bytes / (1024**3), 2), + "used_disk_gb": round(used_disk_bytes / (1024**3), 2), + } + + +@router.get("/activity") +async def get_recent_activity( + limit: int = 10, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """Get recent activity""" + # Recent VMs + recent_vms = ( + db.query(VirtualMachine) + .order_by(VirtualMachine.created_at.desc()) + .limit(limit) + .all() + ) + + # Recent updates + recent_updates = ( + db.query(UpdateLog) + .order_by(UpdateLog.started_at.desc()) + .limit(limit) + .all() + ) + + return { + "recent_vms": [ + { + "id": vm.id, + "name": vm.name, + "status": vm.status.value, + "created_at": vm.created_at.isoformat(), + } + for vm in recent_vms + ], + "recent_updates": [ + { + "id": log.id, + "vm_id": log.vm_id, + "status": log.status, + "packages_updated": log.packages_updated, + "started_at": log.started_at.isoformat(), + } + for log in recent_updates + ], + } diff --git a/src/backend/app/api/docs.py b/src/backend/app/api/docs.py new file mode 100644 index 0000000..e0c5e0a --- /dev/null +++ b/src/backend/app/api/docs.py @@ -0,0 +1,354 @@ +"""Documentation API endpoints""" +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import PlainTextResponse, JSONResponse, FileResponse +from app.api.auth import get_current_user +import logging +import os +import markdown +from weasyprint import HTML, CSS +from datetime import datetime +import tempfile + +logger = logging.getLogger(__name__) + +router = APIRouter() + +# Documentation files directory +DOCS_DIR = "/opt/depl0y/docs" +DOCS_FILES = { + "install": "INSTALL.md", + "deployment": "DEPLOYMENT.md", + "cloud-quickstart": "CLOUD_IMAGES_QUICKSTART.md", + "cloud-guide": "CLOUD_IMAGES_GUIDE.md", + "readme": "README.md", + "proxmox-api-tokens": "PROXMOX_API_TOKENS.md", + "cloud-index": "docs/CLOUD_IMAGES_INDEX.md" +} + + +@router.get("/") +def list_documentation(current_user=Depends(get_current_user)): + """List all available documentation""" + docs = [] + for key, filename in DOCS_FILES.items(): + filepath = os.path.join(DOCS_DIR, filename) + if os.path.exists(filepath): + size = os.path.getsize(filepath) + docs.append({ + "id": key, + "filename": filename, + "title": _get_title(key), + "size": size, + "available": True + }) + else: + docs.append({ + "id": key, + "filename": filename, + "title": _get_title(key), + "available": False + }) + + return {"docs": docs} + + +@router.get("/{doc_id}") +def get_documentation( + doc_id: str, + format: str = "markdown", + current_user=Depends(get_current_user) +): + """Get a specific documentation file""" + if doc_id not in DOCS_FILES: + raise HTTPException(status_code=404, detail="Documentation not found") + + filename = DOCS_FILES[doc_id] + filepath = os.path.join(DOCS_DIR, filename) + + if not os.path.exists(filepath): + raise HTTPException( + status_code=404, + detail=f"Documentation file {filename} not found on server" + ) + + try: + with open(filepath, 'r', encoding='utf-8') as f: + content = f.read() + + if format == "json": + return JSONResponse({ + "id": doc_id, + "filename": filename, + "title": _get_title(doc_id), + "content": content, + "format": "markdown" + }) + else: + # Return plain text markdown + return PlainTextResponse(content, media_type="text/markdown") + + except Exception as e: + logger.error(f"Failed to read documentation {doc_id}: {e}") + raise HTTPException( + status_code=500, + detail=f"Failed to read documentation: {str(e)}" + ) + + +def _get_title(doc_id: str) -> str: + """Get human-readable title for documentation""" + titles = { + "install": "Installation Guide", + "deployment": "Deployment Guide", + "cloud-quickstart": "Cloud Images - Quick Start", + "cloud-guide": "Cloud Images - Complete Guide", + "readme": "Getting Started with Depl0y", + "proxmox-api-tokens": "Proxmox API Tokens Setup", + "cloud-index": "Cloud Images Documentation Index" + } + return titles.get(doc_id, doc_id.replace("-", " ").title()) + + +@router.get("/download/pdf") +def download_documentation_pdf(current_user=Depends(get_current_user)): + """Generate and download complete documentation as PDF""" + try: + # Order of documentation to include in PDF + doc_order = [ + "readme", + "install", + "deployment", + "cloud-quickstart", + "cloud-guide", + "proxmox-api-tokens" + ] + + # Build HTML content + html_content = """ + + + + + Depl0y Documentation + + + + +
+

Depl0y

+

Complete Documentation

+

Automated VM Deployment Panel for Proxmox VE

+

Generated: """ + datetime.now().strftime("%B %d, %Y") + """

+

Version 1.1.3

+
+ + +
+

Table of Contents

+ +
+ """ + + # Add each documentation section + for doc_id in doc_order: + if doc_id not in DOCS_FILES: + continue + + filename = DOCS_FILES[doc_id] + filepath = os.path.join(DOCS_DIR, filename) + + if not os.path.exists(filepath): + logger.warning(f"Documentation file {filename} not found, skipping") + continue + + try: + with open(filepath, 'r', encoding='utf-8') as f: + md_content = f.read() + + # Convert markdown to HTML + html_section = markdown.markdown( + md_content, + extensions=['extra', 'codehilite', 'tables', 'toc'] + ) + + html_content += f'
\n{html_section}\n
\n' + + except Exception as e: + logger.error(f"Failed to process {doc_id}: {e}") + continue + + html_content += """ + + + """ + + # Generate PDF + logger.info("Generating PDF from HTML...") + + # Create temporary file for PDF + with tempfile.NamedTemporaryFile(delete=False, suffix='.pdf') as tmp_file: + pdf_path = tmp_file.name + + # Generate PDF using WeasyPrint + HTML(string=html_content).write_pdf(pdf_path) + + logger.info(f"PDF generated successfully at {pdf_path}") + + # Return PDF file + filename = f"Depl0y_Documentation_{datetime.now().strftime('%Y%m%d')}.pdf" + + return FileResponse( + pdf_path, + media_type="application/pdf", + filename=filename, + headers={ + "Content-Disposition": f"attachment; filename={filename}" + } + ) + + except Exception as e: + logger.error(f"Failed to generate PDF: {e}", exc_info=True) + raise HTTPException( + status_code=500, + detail=f"Failed to generate PDF: {str(e)}" + ) diff --git a/src/backend/app/api/ha.py b/src/backend/app/api/ha.py new file mode 100644 index 0000000..12ada74 --- /dev/null +++ b/src/backend/app/api/ha.py @@ -0,0 +1,524 @@ +"""High Availability API endpoints""" +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from app.api.auth import get_current_user, require_admin +from app.core.database import get_db +from sqlalchemy.orm import Session +import logging +import subprocess + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +class HAEnableRequest(BaseModel): + proxmox_password: str + + +@router.get("/status") +def check_ha_status(current_user=Depends(get_current_user), db: Session = Depends(get_db)): + """Check if High Availability is enabled on Proxmox cluster""" + try: + from app.models import ProxmoxHost + import json + + # Get first Proxmox host to check HA status + host = db.query(ProxmoxHost).first() + if not host: + return { + "enabled": False, + "protected_vms": 0, + "manager_status": "unknown", + "quorum": False, + "message": "No Proxmox hosts configured" + } + + # Check if HA is enabled using pvesh + ssh_host = f"root@{host.hostname}" + check_ha = f"ssh -o StrictHostKeyChecking=no -o BatchMode=yes {ssh_host} 'pvesh get /cluster/ha/status/manager_status --output-format json 2>/dev/null'" + + result = subprocess.run(check_ha, shell=True, capture_output=True, timeout=10, text=True) + + manager_status = "unknown" + quorum = False + protected_vms = 0 + + if result.returncode == 0 and result.stdout: + try: + ha_data = json.loads(result.stdout) + + # Parse quorum status + if "quorum" in ha_data and isinstance(ha_data["quorum"], dict): + quorum = ha_data["quorum"].get("quorate") == "1" + + # Parse manager status + if "manager_status" in ha_data and isinstance(ha_data["manager_status"], dict): + node_status = ha_data["manager_status"].get("node_status", {}) + if node_status: + # Get first node's status + first_node = next(iter(node_status.values()), {}) + manager_status = first_node.get("state", "unknown") + else: + manager_status = "active" + + # Get number of protected resources + get_resources = f"ssh -o StrictHostKeyChecking=no -o BatchMode=yes {ssh_host} 'pvesh get /cluster/ha/resources --output-format json 2>/dev/null'" + resources_result = subprocess.run(get_resources, shell=True, capture_output=True, timeout=10, text=True) + + if resources_result.returncode == 0: + try: + resources = json.loads(resources_result.stdout) + protected_vms = len(resources) if isinstance(resources, list) else 0 + except: + pass + + return { + "enabled": True, + "protected_vms": protected_vms, + "manager_status": manager_status, + "quorum": quorum, + "message": "High Availability is enabled" + } + except json.JSONDecodeError: + logger.error(f"Failed to parse HA status JSON: {result.stdout}") + + return { + "enabled": False, + "protected_vms": 0, + "manager_status": "unknown", + "quorum": False, + "message": "High Availability not configured" + } + + except Exception as e: + logger.error(f"Failed to check HA status: {e}") + return { + "enabled": False, + "protected_vms": 0, + "manager_status": "unknown", + "quorum": False, + "message": "Failed to check HA status" + } + + +@router.post("/enable") +def enable_ha( + request: HAEnableRequest, + current_user=Depends(require_admin), + db: Session = Depends(get_db) +): + """Enable High Availability on Proxmox cluster""" + try: + from app.models import ProxmoxHost + + host = db.query(ProxmoxHost).first() + if not host: + raise HTTPException(status_code=400, detail="No Proxmox hosts configured") + + ssh_host = f"root@{host.hostname}" + logger.info(f"Enabling HA on {ssh_host}") + + # Enable HA Manager + # This requires the cluster to have a quorum + enable_script = f""" +ssh -o StrictHostKeyChecking=no -o BatchMode=yes {ssh_host} ' +# Check if cluster has quorum +if ! pvesh get /cluster/status 2>/dev/null | grep -q "quorate.*1"; then + echo "ERROR: Cluster does not have quorum. HA requires a multi-node cluster with quorum." + exit 1 +fi + +# HA Manager is usually enabled by default if cluster exists +# Just verify it is running +if ! systemctl is-active --quiet pve-ha-lrm && ! systemctl is-active --quiet pve-ha-crm; then + echo "Starting HA services..." + systemctl start pve-ha-lrm + systemctl start pve-ha-crm + systemctl enable pve-ha-lrm + systemctl enable pve-ha-crm +fi + +echo "HA services are running" +' +""" + + result = subprocess.run(enable_script, shell=True, capture_output=True, timeout=30) + + if result.returncode != 0: + error_msg = result.stderr.decode() if result.stderr else result.stdout.decode() + if "does not have quorum" in error_msg or "quorate" in error_msg: + raise HTTPException( + status_code=400, + detail="High Availability requires a multi-node Proxmox cluster with quorum. Single-node setups cannot use HA." + ) + raise HTTPException(status_code=500, detail=f"Failed to enable HA: {error_msg}") + + logger.info("HA enabled successfully") + return { + "success": True, + "message": "High Availability enabled successfully. You can now add VMs to HA groups via the Proxmox web interface." + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to enable HA: {e}") + raise HTTPException(status_code=500, detail=f"Failed to enable HA: {str(e)}") + + +@router.get("/groups") +def list_ha_groups( + current_user=Depends(get_current_user), + db: Session = Depends(get_db) +): + """List HA groups (migrated to rules in Proxmox 8+)""" + try: + from app.models import ProxmoxHost + + host = db.query(ProxmoxHost).first() + if not host: + return {"groups": [], "message": "No Proxmox hosts configured"} + + ssh_host = f"root@{host.hostname}" + + # Get HA groups - handle migration to rules + get_groups = f"ssh -o StrictHostKeyChecking=no -o BatchMode=yes {ssh_host} 'pvesh get /cluster/ha/groups --output-format json 2>&1'" + result = subprocess.run(get_groups, shell=True, capture_output=True, timeout=10, text=True) + + # Check if groups migrated to rules (Proxmox 8+) + if "migrated to rules" in result.stderr or "migrated to rules" in result.stdout: + return { + "groups": [], + "message": "HA groups migrated to rules in Proxmox 8+. Manage via Proxmox web interface." + } + + if result.returncode == 0: + import json + try: + groups = json.loads(result.stdout) + return {"groups": groups if isinstance(groups, list) else []} + except: + return {"groups": []} + else: + return {"groups": [], "message": "HA groups not configured"} + + except Exception as e: + logger.error(f"Failed to list HA groups: {e}") + return {"groups": [], "error": str(e)} + + +@router.post("/disable") +def disable_ha( + current_user=Depends(require_admin), + db: Session = Depends(get_db) +): + """Disable High Availability (note: this just stops the services, VMs remain in HA config)""" + try: + from app.models import ProxmoxHost + + host = db.query(ProxmoxHost).first() + if not host: + raise HTTPException(status_code=400, detail="No Proxmox hosts configured") + + ssh_host = f"root@{host.hostname}" + + # Stop HA services + disable_script = f""" +ssh -o StrictHostKeyChecking=no -o BatchMode=yes {ssh_host} ' +systemctl stop pve-ha-lrm +systemctl stop pve-ha-crm +systemctl disable pve-ha-lrm +systemctl disable pve-ha-crm +echo "HA services stopped" +' +""" + + result = subprocess.run(disable_script, shell=True, capture_output=True, timeout=30) + + if result.returncode != 0: + error_msg = result.stderr.decode() if result.stderr else "Unknown error" + raise HTTPException(status_code=500, detail=f"Failed to disable HA: {error_msg}") + + return { + "success": True, + "message": "HA services disabled. VMs remain in HA configuration but will not be automatically restarted." + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to disable HA: {e}") + raise HTTPException(status_code=500, detail=f"Failed to disable HA: {str(e)}") + + +class HAGroupCreate(BaseModel): + group: str + nodes: str # Comma-separated list of nodes + restricted: int = 0 # 0 or 1 + nofailback: int = 0 # 0 or 1 + comment: str = None + + +@router.post("/groups") +def create_ha_group( + request: HAGroupCreate, + current_user=Depends(require_admin), + db: Session = Depends(get_db) +): + """Create a new HA group""" + try: + from app.models import ProxmoxHost + + host = db.query(ProxmoxHost).first() + if not host: + raise HTTPException(status_code=400, detail="No Proxmox hosts configured") + + ssh_host = f"root@{host.hostname}" + + # Build pvesh command to create HA group + cmd = f"pvesh create /cluster/ha/groups -group {request.group} -nodes {request.nodes}" + if request.restricted: + cmd += f" -restricted {request.restricted}" + if request.nofailback: + cmd += f" -nofailback {request.nofailback}" + if request.comment: + cmd += f" -comment '{request.comment}'" + + create_cmd = f"ssh -o StrictHostKeyChecking=no -o BatchMode=yes {ssh_host} '{cmd}'" + result = subprocess.run(create_cmd, shell=True, capture_output=True, timeout=10) + + if result.returncode != 0: + error_msg = result.stderr.decode() if result.stderr else "Unknown error" + raise HTTPException(status_code=500, detail=f"Failed to create HA group: {error_msg}") + + logger.info(f"Created HA group {request.group}") + return { + "success": True, + "message": f"HA group {request.group} created successfully" + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to create HA group: {e}") + raise HTTPException(status_code=500, detail=f"Failed to create HA group: {str(e)}") + + +class HAGroupUpdate(BaseModel): + nodes: str = None + restricted: int = None + nofailback: int = None + comment: str = None + + +@router.put("/groups/{group_id}") +def update_ha_group( + group_id: str, + request: HAGroupUpdate, + current_user=Depends(require_admin), + db: Session = Depends(get_db) +): + """Update an existing HA group""" + try: + from app.models import ProxmoxHost + + host = db.query(ProxmoxHost).first() + if not host: + raise HTTPException(status_code=400, detail="No Proxmox hosts configured") + + ssh_host = f"root@{host.hostname}" + + # Build pvesh command to update HA group + cmd = f"pvesh set /cluster/ha/groups/{group_id}" + if request.nodes: + cmd += f" -nodes {request.nodes}" + if request.restricted is not None: + cmd += f" -restricted {request.restricted}" + if request.nofailback is not None: + cmd += f" -nofailback {request.nofailback}" + if request.comment is not None: + cmd += f" -comment '{request.comment}'" + + update_cmd = f"ssh -o StrictHostKeyChecking=no -o BatchMode=yes {ssh_host} '{cmd}'" + result = subprocess.run(update_cmd, shell=True, capture_output=True, timeout=10) + + if result.returncode != 0: + error_msg = result.stderr.decode() if result.stderr else "Unknown error" + raise HTTPException(status_code=500, detail=f"Failed to update HA group: {error_msg}") + + logger.info(f"Updated HA group {group_id}") + return { + "success": True, + "message": f"HA group {group_id} updated successfully" + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to update HA group: {e}") + raise HTTPException(status_code=500, detail=f"Failed to update HA group: {str(e)}") + + +@router.delete("/groups/{group_id}") +def delete_ha_group( + group_id: str, + current_user=Depends(require_admin), + db: Session = Depends(get_db) +): + """Delete an HA group""" + try: + from app.models import ProxmoxHost + + host = db.query(ProxmoxHost).first() + if not host: + raise HTTPException(status_code=400, detail="No Proxmox hosts configured") + + ssh_host = f"root@{host.hostname}" + + delete_cmd = f"ssh -o StrictHostKeyChecking=no -o BatchMode=yes {ssh_host} 'pvesh delete /cluster/ha/groups/{group_id}'" + result = subprocess.run(delete_cmd, shell=True, capture_output=True, timeout=10) + + if result.returncode != 0: + error_msg = result.stderr.decode() if result.stderr else "Unknown error" + raise HTTPException(status_code=500, detail=f"Failed to delete HA group: {error_msg}") + + logger.info(f"Deleted HA group {group_id}") + return { + "success": True, + "message": f"HA group {group_id} deleted successfully" + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to delete HA group: {e}") + raise HTTPException(status_code=500, detail=f"Failed to delete HA group: {str(e)}") + + +@router.get("/resources") +def list_ha_resources( + current_user=Depends(get_current_user), + db: Session = Depends(get_db) +): + """List all HA-protected resources""" + try: + from app.models import ProxmoxHost + + host = db.query(ProxmoxHost).first() + if not host: + raise HTTPException(status_code=400, detail="No Proxmox hosts configured") + + ssh_host = f"root@{host.hostname}" + + get_resources = f"ssh -o StrictHostKeyChecking=no -o BatchMode=yes {ssh_host} 'pvesh get /cluster/ha/resources --output-format json 2>/dev/null'" + result = subprocess.run(get_resources, shell=True, capture_output=True, timeout=10) + + if result.returncode == 0: + import json + try: + resources = json.loads(result.stdout.decode()) + return {"resources": resources if isinstance(resources, list) else []} + except: + return {"resources": []} + else: + return {"resources": []} + + except Exception as e: + logger.error(f"Failed to list HA resources: {e}") + raise HTTPException(status_code=500, detail=f"Failed to list HA resources: {str(e)}") + + +class HAResourceAdd(BaseModel): + sid: str # Resource ID (e.g., "vm:100") + group: str = None # HA group name + max_relocate: int = 1 # Maximum relocate attempts + max_restart: int = 1 # Maximum restart attempts + state: str = "started" # started, stopped, ignored, disabled + comment: str = None + + +@router.post("/resources") +def add_ha_resource( + request: HAResourceAdd, + current_user=Depends(require_admin), + db: Session = Depends(get_db) +): + """Add a VM to HA protection""" + try: + from app.models import ProxmoxHost + + host = db.query(ProxmoxHost).first() + if not host: + raise HTTPException(status_code=400, detail="No Proxmox hosts configured") + + ssh_host = f"root@{host.hostname}" + + # Build pvesh command to add HA resource + cmd = f"pvesh create /cluster/ha/resources -sid {request.sid}" + if request.group: + cmd += f" -group {request.group}" + cmd += f" -max_relocate {request.max_relocate}" + cmd += f" -max_restart {request.max_restart}" + cmd += f" -state {request.state}" + if request.comment: + cmd += f" -comment '{request.comment}'" + + add_cmd = f"ssh -o StrictHostKeyChecking=no -o BatchMode=yes {ssh_host} '{cmd}'" + result = subprocess.run(add_cmd, shell=True, capture_output=True, timeout=10) + + if result.returncode != 0: + error_msg = result.stderr.decode() if result.stderr else "Unknown error" + raise HTTPException(status_code=500, detail=f"Failed to add HA resource: {error_msg}") + + logger.info(f"Added HA resource {request.sid}") + return { + "success": True, + "message": f"Resource {request.sid} added to HA protection" + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to add HA resource: {e}") + raise HTTPException(status_code=500, detail=f"Failed to add HA resource: {str(e)}") + + +@router.delete("/resources/{sid}") +def remove_ha_resource( + sid: str, + current_user=Depends(require_admin), + db: Session = Depends(get_db) +): + """Remove a VM from HA protection""" + try: + from app.models import ProxmoxHost + + host = db.query(ProxmoxHost).first() + if not host: + raise HTTPException(status_code=400, detail="No Proxmox hosts configured") + + ssh_host = f"root@{host.hostname}" + + # URL encode the sid (e.g., vm:100 becomes vm%3A100) + import urllib.parse + encoded_sid = urllib.parse.quote(sid, safe='') + + delete_cmd = f"ssh -o StrictHostKeyChecking=no -o BatchMode=yes {ssh_host} 'pvesh delete /cluster/ha/resources/{encoded_sid}'" + result = subprocess.run(delete_cmd, shell=True, capture_output=True, timeout=10) + + if result.returncode != 0: + error_msg = result.stderr.decode() if result.stderr else "Unknown error" + raise HTTPException(status_code=500, detail=f"Failed to remove HA resource: {error_msg}") + + logger.info(f"Removed HA resource {sid}") + return { + "success": True, + "message": f"Resource {sid} removed from HA protection" + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to remove HA resource: {e}") + raise HTTPException(status_code=500, detail=f"Failed to remove HA resource: {str(e)}") diff --git a/src/backend/app/api/isos.py b/src/backend/app/api/isos.py new file mode 100644 index 0000000..37be527 --- /dev/null +++ b/src/backend/app/api/isos.py @@ -0,0 +1,423 @@ +"""ISO Images API routes""" +from fastapi import APIRouter, Depends, HTTPException, status, UploadFile, File, BackgroundTasks +from sqlalchemy.orm import Session +from pydantic import BaseModel, HttpUrl +from typing import List, Optional, Dict +from datetime import datetime +import os +import hashlib +import shutil +import requests +import tempfile +import logging + +from app.core.database import get_db +from app.core.config import settings +from app.models import ISOImage, OSType, User +from app.api.auth import get_current_user, require_operator + +router = APIRouter() +logger = logging.getLogger(__name__) + +# In-memory progress tracking +upload_progress: Dict[str, Dict] = {} + + +# Pydantic models +class ISOImageResponse(BaseModel): + id: int + name: str + filename: str + os_type: OSType + version: Optional[str] + architecture: str + file_size: Optional[int] + checksum: Optional[str] + storage_path: str + created_at: datetime + is_available: bool + + class Config: + from_attributes = True + + +class ISODownloadRequest(BaseModel): + url: str + name: str + os_type: OSType + version: Optional[str] = None + architecture: str = "amd64" + + +def process_iso_file( + temp_path: str, + storage_path: str, + upload_id: str, + iso_id: int, + db_session +): + """Process ISO file in background: copy with progress and calculate checksum""" + try: + upload_progress[upload_id] = { + "status": "copying", + "progress": 0, + "message": "Copying file to storage..." + } + + # Get file size + file_size = os.path.getsize(temp_path) + + # Copy with progress tracking + bytes_copied = 0 + chunk_size = 1024 * 1024 # 1MB chunks + + with open(temp_path, 'rb') as src: + with open(storage_path, 'wb') as dst: + while True: + chunk = src.read(chunk_size) + if not chunk: + break + dst.write(chunk) + bytes_copied += len(chunk) + progress = int((bytes_copied / file_size) * 50) # First 50% for copying + upload_progress[upload_id]["progress"] = progress + + # Remove temp file + os.remove(temp_path) + + # Calculate checksum with progress + upload_progress[upload_id].update({ + "status": "calculating_checksum", + "progress": 50, + "message": "Calculating checksum..." + }) + + sha256_hash = hashlib.sha256() + bytes_hashed = 0 + + with open(storage_path, "rb") as f: + while True: + chunk = f.read(chunk_size) + if not chunk: + break + sha256_hash.update(chunk) + bytes_hashed += len(chunk) + progress = 50 + int((bytes_hashed / file_size) * 50) # Last 50% for checksum + upload_progress[upload_id]["progress"] = progress + + checksum = sha256_hash.hexdigest() + + # Update database + from app.core.database import SessionLocal + db = SessionLocal() + try: + iso = db.query(ISOImage).filter(ISOImage.id == iso_id).first() + if iso: + iso.checksum = checksum + db.commit() + finally: + db.close() + + upload_progress[upload_id] = { + "status": "completed", + "progress": 100, + "message": "Upload completed successfully", + "checksum": checksum + } + + except Exception as e: + logger.error(f"Error processing ISO: {e}") + upload_progress[upload_id] = { + "status": "error", + "progress": 0, + "message": f"Error: {str(e)}" + } + # Clean up on error + if os.path.exists(storage_path): + os.remove(storage_path) + + +@router.get("/", response_model=List[ISOImageResponse]) +async def list_isos( + skip: int = 0, + limit: int = 100, + os_type: Optional[OSType] = None, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """List all ISO images""" + query = db.query(ISOImage) + + if os_type: + query = query.filter(ISOImage.os_type == os_type) + + isos = query.filter(ISOImage.is_available == True).offset(skip).limit(limit).all() + return isos + + +@router.post("/", response_model=ISOImageResponse, status_code=status.HTTP_201_CREATED) +async def upload_iso( + background_tasks: BackgroundTasks, + file: UploadFile = File(...), + name: str = None, + os_type: OSType = OSType.UBUNTU, + version: str = None, + architecture: str = "amd64", + current_user: User = Depends(require_operator), + db: Session = Depends(get_db), +): + """Upload an ISO image with progress tracking""" + # Validate file extension + if not file.filename.endswith('.iso'): + raise HTTPException(status_code=400, detail="Only ISO files are allowed") + + # Create storage directory if it doesn't exist + os.makedirs(settings.ISO_STORAGE_PATH, exist_ok=True) + + # Generate unique filename + filename = file.filename + storage_path = os.path.join(settings.ISO_STORAGE_PATH, filename) + + # Check if file already exists + if os.path.exists(storage_path): + raise HTTPException(status_code=400, detail="ISO file already exists") + + try: + # Save to temporary file first (fast - already in memory from upload) + with tempfile.NamedTemporaryFile(delete=False, suffix='.iso') as tmp_file: + shutil.copyfileobj(file.file, tmp_file) + temp_path = tmp_file.name + + # Calculate file size + file_size = os.path.getsize(temp_path) + + if file_size > settings.MAX_ISO_SIZE: + os.remove(temp_path) + raise HTTPException( + status_code=400, + detail=f"File too large. Maximum size is {settings.MAX_ISO_SIZE / (1024**3)}GB" + ) + + # Create database record with "processing" status + iso_name = name or filename.replace('.iso', '') + new_iso = ISOImage( + name=iso_name, + filename=filename, + os_type=os_type, + version=version, + architecture=architecture, + file_size=file_size, + checksum="processing...", + storage_path=storage_path, + uploaded_by=current_user.id, + is_available=True, + ) + + db.add(new_iso) + db.commit() + db.refresh(new_iso) + + # Generate upload ID for progress tracking + upload_id = f"upload_{new_iso.id}" + upload_progress[upload_id] = { + "status": "queued", + "progress": 0, + "message": "Upload queued for processing..." + } + + # Process file in background + background_tasks.add_task( + process_iso_file, + temp_path, + storage_path, + upload_id, + new_iso.id, + db + ) + + return new_iso + + except Exception as e: + # Clean up temp file if database operation fails + if 'temp_path' in locals() and os.path.exists(temp_path): + os.remove(temp_path) + raise HTTPException(status_code=500, detail=f"Failed to upload ISO: {str(e)}") + + +@router.post("/download", response_model=ISOImageResponse, status_code=status.HTTP_201_CREATED) +async def download_iso_from_url( + download_request: ISODownloadRequest, + current_user: User = Depends(require_operator), + db: Session = Depends(get_db), +): + """Download an ISO image from a URL""" + # Create storage directory if it doesn't exist + os.makedirs(settings.ISO_STORAGE_PATH, exist_ok=True) + + # Extract filename from URL or use provided name + url_filename = download_request.url.split('/')[-1] + if not url_filename.endswith('.iso'): + url_filename = download_request.name.replace(' ', '_') + '.iso' + + filename = url_filename + storage_path = os.path.join(settings.ISO_STORAGE_PATH, filename) + + # Check if file already exists + if os.path.exists(storage_path): + raise HTTPException(status_code=400, detail="ISO file already exists") + + try: + # Download file from URL + response = requests.get(download_request.url, stream=True, timeout=30) + response.raise_for_status() + + # Get total file size if available + total_size = int(response.headers.get('content-length', 0)) + + if total_size > settings.MAX_ISO_SIZE: + raise HTTPException( + status_code=400, + detail=f"File too large. Maximum size is {settings.MAX_ISO_SIZE / (1024**3)}GB" + ) + + # Download to temporary file first + with tempfile.NamedTemporaryFile(delete=False, suffix='.iso') as tmp_file: + for chunk in response.iter_content(chunk_size=8192): + if chunk: + tmp_file.write(chunk) + tmp_path = tmp_file.name + + # Move to final location + shutil.move(tmp_path, storage_path) + + # Calculate file size + file_size = os.path.getsize(storage_path) + + # Set checksum to calculating for now + checksum = "calculating..." + + # Create database record + new_iso = ISOImage( + name=download_request.name, + filename=filename, + os_type=download_request.os_type, + version=download_request.version, + architecture=download_request.architecture, + file_size=file_size, + checksum=checksum, + storage_path=storage_path, + uploaded_by=current_user.id, + is_available=True, + ) + + db.add(new_iso) + db.commit() + db.refresh(new_iso) + + return new_iso + + except requests.exceptions.RequestException as e: + # Clean up file if download fails + if os.path.exists(storage_path): + os.remove(storage_path) + raise HTTPException(status_code=400, detail=f"Failed to download ISO: {str(e)}") + except Exception as e: + # Clean up file if database operation fails + if os.path.exists(storage_path): + os.remove(storage_path) + raise HTTPException(status_code=500, detail=f"Failed to download ISO: {str(e)}") + + +@router.get("/{iso_id}", response_model=ISOImageResponse) +async def get_iso( + iso_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """Get ISO image by ID""" + iso = db.query(ISOImage).filter(ISOImage.id == iso_id).first() + if not iso: + raise HTTPException(status_code=404, detail="ISO not found") + return iso + + +@router.delete("/{iso_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_iso( + iso_id: int, + current_user: User = Depends(require_operator), + db: Session = Depends(get_db), +): + """Delete ISO image""" + iso = db.query(ISOImage).filter(ISOImage.id == iso_id).first() + if not iso: + raise HTTPException(status_code=404, detail="ISO not found") + + # Delete file from storage + if os.path.exists(iso.storage_path): + try: + os.remove(iso.storage_path) + except Exception as e: + raise HTTPException(status_code=500, detail=f"Failed to delete file: {str(e)}") + + # Delete from database + db.delete(iso) + db.commit() + + return None + + +@router.get("/{iso_id}/progress") +async def get_upload_progress( + iso_id: int, + current_user: User = Depends(get_current_user), +): + """Get upload progress for an ISO""" + upload_id = f"upload_{iso_id}" + + if upload_id not in upload_progress: + return { + "status": "not_found", + "progress": 100, + "message": "Upload completed or not tracked" + } + + return upload_progress[upload_id] + + +@router.post("/{iso_id}/verify") +async def verify_iso( + iso_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """Verify ISO checksum""" + iso = db.query(ISOImage).filter(ISOImage.id == iso_id).first() + if not iso: + raise HTTPException(status_code=404, detail="ISO not found") + + if not os.path.exists(iso.storage_path): + iso.is_available = False + db.commit() + raise HTTPException(status_code=404, detail="ISO file not found on disk") + + # Calculate current checksum + sha256_hash = hashlib.sha256() + with open(iso.storage_path, "rb") as f: + for byte_block in iter(lambda: f.read(4096), b""): + sha256_hash.update(byte_block) + current_checksum = sha256_hash.hexdigest() + + # Compare with stored checksum + if current_checksum == iso.checksum: + return { + "status": "valid", + "message": "ISO checksum matches", + "checksum": current_checksum, + } + else: + return { + "status": "invalid", + "message": "ISO checksum does not match", + "expected": iso.checksum, + "actual": current_checksum, + } diff --git a/src/backend/app/api/logs.py b/src/backend/app/api/logs.py new file mode 100644 index 0000000..ed47308 --- /dev/null +++ b/src/backend/app/api/logs.py @@ -0,0 +1,73 @@ +"""System logs API routes""" +from fastapi import APIRouter, Depends, HTTPException +from typing import Optional +import subprocess +import logging +from app.api.auth import get_current_user, require_admin +from app.models import User + +router = APIRouter() +logger = logging.getLogger(__name__) + + +@router.get("/backend") +async def get_backend_logs( + lines: Optional[int] = 100, + current_user: User = Depends(require_admin), +): + """Get backend service logs (admin only)""" + try: + # Get logs from systemd journal (requires sudo) + result = subprocess.run( + ["sudo", "journalctl", "-u", "depl0y-backend", "-n", str(lines), "--no-pager"], + capture_output=True, + text=True, + timeout=10 + ) + + if result.returncode != 0: + raise Exception(f"Failed to retrieve logs: {result.stderr}") + + return { + "logs": result.stdout, + "lines": lines + } + + except subprocess.TimeoutExpired: + raise HTTPException(status_code=500, detail="Timeout while retrieving logs") + except Exception as e: + logger.error(f"Failed to get backend logs: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/backend/tail") +async def tail_backend_logs( + lines: Optional[int] = 50, + current_user: User = Depends(require_admin), +): + """Get most recent backend logs (admin only)""" + try: + # Get most recent logs (requires sudo) + result = subprocess.run( + ["sudo", "journalctl", "-u", "depl0y-backend", "-n", str(lines), "--no-pager", "-o", "cat"], + capture_output=True, + text=True, + timeout=10 + ) + + if result.returncode != 0: + raise Exception(f"Failed to retrieve logs: {result.stderr}") + + # Return as array of lines for easier frontend processing + log_lines = result.stdout.strip().split('\n') if result.stdout else [] + + return { + "logs": log_lines, + "count": len(log_lines) + } + + except subprocess.TimeoutExpired: + raise HTTPException(status_code=500, detail="Timeout while retrieving logs") + except Exception as e: + logger.error(f"Failed to tail backend logs: {e}") + raise HTTPException(status_code=500, detail=str(e)) diff --git a/src/backend/app/api/proxmox.py b/src/backend/app/api/proxmox.py new file mode 100644 index 0000000..80b97ad --- /dev/null +++ b/src/backend/app/api/proxmox.py @@ -0,0 +1,384 @@ +"""Proxmox hosts API routes""" +from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks +from sqlalchemy.orm import Session +from pydantic import BaseModel +from typing import List, Optional +from datetime import datetime + +from app.core.database import get_db +from app.core.security import encrypt_data, decrypt_data +from app.models import ProxmoxHost, ProxmoxNode +from app.api.auth import get_current_user, require_admin +from app.models import User +from app.services.proxmox import ProxmoxService, poll_proxmox_resources + +router = APIRouter() + + +# Pydantic models +class ProxmoxHostCreate(BaseModel): + name: str + hostname: str + port: int = 8006 + username: str + password: Optional[str] = None + api_token_id: Optional[str] = None # e.g., "mytoken" or "root@pam!mytoken" + api_token_secret: Optional[str] = None + verify_ssl: bool = False + + +class ProxmoxHostUpdate(BaseModel): + name: Optional[str] = None + hostname: Optional[str] = None + port: Optional[int] = None + username: Optional[str] = None + password: Optional[str] = None + api_token_id: Optional[str] = None + api_token_secret: Optional[str] = None + verify_ssl: Optional[bool] = None + is_active: Optional[bool] = None + + +class ProxmoxHostResponse(BaseModel): + id: int + name: str + hostname: str + port: int + username: str + verify_ssl: bool + is_active: bool + last_poll: Optional[datetime] + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class ProxmoxNodeResponse(BaseModel): + id: int + host_id: int + node_name: str + status: Optional[str] + cpu_cores: Optional[int] + cpu_usage: Optional[int] + memory_total: Optional[int] + memory_used: Optional[int] + disk_total: Optional[int] + disk_used: Optional[int] + uptime: Optional[int] + last_updated: datetime + + class Config: + from_attributes = True + + +@router.get("/", response_model=List[ProxmoxHostResponse]) +async def list_proxmox_hosts( + skip: int = 0, + limit: int = 100, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """List all Proxmox hosts""" + hosts = db.query(ProxmoxHost).offset(skip).limit(limit).all() + return hosts + + +@router.post("/", response_model=ProxmoxHostResponse, status_code=status.HTTP_201_CREATED) +async def create_proxmox_host( + host_data: ProxmoxHostCreate, + current_user: User = Depends(require_admin), + db: Session = Depends(get_db), +): + """Create a new Proxmox host (admin only)""" + import logging + logger = logging.getLogger(__name__) + logger.info(f"Received Proxmox host create request: name={host_data.name}, hostname={host_data.hostname}, username={host_data.username}, has_password={bool(host_data.password)}, has_token_id={bool(host_data.api_token_id)}, has_token_secret={bool(host_data.api_token_secret)}") + + # Check if name already exists + existing_host = db.query(ProxmoxHost).filter(ProxmoxHost.name == host_data.name).first() + if existing_host: + raise HTTPException(status_code=400, detail="Host name already exists") + + # Validate that either password or API token is provided + if not host_data.password and not (host_data.api_token_id and host_data.api_token_secret): + raise HTTPException( + status_code=400, + detail="Either password or API token (both token ID and secret) must be provided" + ) + + # Encrypt password if provided + encrypted_password = encrypt_data(host_data.password) if host_data.password else None + + # Encrypt API token secret if provided + encrypted_token_secret = encrypt_data(host_data.api_token_secret) if host_data.api_token_secret else None + + # Create new host + new_host = ProxmoxHost( + name=host_data.name, + hostname=host_data.hostname, + port=host_data.port, + username=host_data.username, + password=encrypted_password, + api_token_id=host_data.api_token_id, + api_token_secret=encrypted_token_secret, + verify_ssl=host_data.verify_ssl, + ) + + db.add(new_host) + db.commit() + db.refresh(new_host) + + # Test connection + try: + service = ProxmoxService(new_host) + if not service.test_connection(): + raise HTTPException( + status_code=400, detail="Cannot connect to Proxmox host. Check credentials and connectivity." + ) + except Exception as e: + db.delete(new_host) + db.commit() + raise HTTPException(status_code=400, detail=f"Failed to connect: {str(e)}") + + return new_host + + +@router.get("/{host_id}", response_model=ProxmoxHostResponse) +async def get_proxmox_host( + host_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """Get Proxmox host by ID""" + host = db.query(ProxmoxHost).filter(ProxmoxHost.id == host_id).first() + if not host: + raise HTTPException(status_code=404, detail="Host not found") + return host + + +@router.put("/{host_id}", response_model=ProxmoxHostResponse) +async def update_proxmox_host( + host_id: int, + host_data: ProxmoxHostUpdate, + current_user: User = Depends(require_admin), + db: Session = Depends(get_db), +): + """Update Proxmox host (admin only)""" + host = db.query(ProxmoxHost).filter(ProxmoxHost.id == host_id).first() + if not host: + raise HTTPException(status_code=404, detail="Host not found") + + # Update fields + if host_data.name is not None: + # Check if name already exists + existing_host = ( + db.query(ProxmoxHost) + .filter(ProxmoxHost.name == host_data.name, ProxmoxHost.id != host_id) + .first() + ) + if existing_host: + raise HTTPException(status_code=400, detail="Host name already exists") + host.name = host_data.name + + if host_data.hostname is not None: + host.hostname = host_data.hostname + + if host_data.port is not None: + host.port = host_data.port + + if host_data.username is not None: + host.username = host_data.username + + if host_data.password is not None: + host.password = encrypt_data(host_data.password) + + if host_data.api_token_id is not None: + host.api_token_id = host_data.api_token_id + + if host_data.api_token_secret is not None: + host.api_token_secret = encrypt_data(host_data.api_token_secret) + + if host_data.verify_ssl is not None: + host.verify_ssl = host_data.verify_ssl + + if host_data.is_active is not None: + host.is_active = host_data.is_active + + host.updated_at = datetime.utcnow() + db.commit() + db.refresh(host) + + return host + + +@router.delete("/{host_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_proxmox_host( + host_id: int, + current_user: User = Depends(require_admin), + db: Session = Depends(get_db), +): + """Delete Proxmox host (admin only)""" + host = db.query(ProxmoxHost).filter(ProxmoxHost.id == host_id).first() + if not host: + raise HTTPException(status_code=404, detail="Host not found") + + db.delete(host) + db.commit() + + return None + + +@router.post("/{host_id}/test") +async def test_proxmox_connection( + host_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """Test connection to Proxmox host""" + host = db.query(ProxmoxHost).filter(ProxmoxHost.id == host_id).first() + if not host: + raise HTTPException(status_code=404, detail="Host not found") + + try: + service = ProxmoxService(host) + success = service.test_connection() + + if success: + return {"status": "success", "message": "Connection successful"} + else: + return {"status": "error", "message": "Connection failed"} + except Exception as e: + return {"status": "error", "message": str(e)} + + +@router.post("/{host_id}/poll") +async def poll_proxmox_host( + host_id: int, + background_tasks: BackgroundTasks, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """Poll Proxmox host for resources""" + host = db.query(ProxmoxHost).filter(ProxmoxHost.id == host_id).first() + if not host: + raise HTTPException(status_code=404, detail="Host not found") + + # Run polling in background + background_tasks.add_task(poll_proxmox_resources, db, host_id) + + return {"status": "success", "message": "Polling started"} + + +@router.get("/{host_id}/nodes", response_model=List[ProxmoxNodeResponse]) +async def list_proxmox_nodes( + host_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """List nodes for a Proxmox host""" + host = db.query(ProxmoxHost).filter(ProxmoxHost.id == host_id).first() + if not host: + raise HTTPException(status_code=404, detail="Host not found") + + nodes = db.query(ProxmoxNode).filter(ProxmoxNode.host_id == host_id).all() + return nodes + + +@router.get("/{host_id}/stats") +async def get_datacenter_stats( + host_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """Get datacenter statistics including VM counts""" + from app.models import VirtualMachine, VMStatus + from sqlalchemy import func + + host = db.query(ProxmoxHost).filter(ProxmoxHost.id == host_id).first() + if not host: + raise HTTPException(status_code=404, detail="Host not found") + + # Get VM counts by status + vm_counts = db.query( + VirtualMachine.status, + func.count(VirtualMachine.id).label('count') + ).filter( + VirtualMachine.proxmox_host_id == host_id + ).group_by(VirtualMachine.status).all() + + # Convert to dict + status_counts = {status: count for status, count in vm_counts} + + # Get total VMs + total_vms = sum(status_counts.values()) + + return { + "total_vms": total_vms, + "running": status_counts.get(VMStatus.RUNNING, 0), + "stopped": status_counts.get(VMStatus.STOPPED, 0), + "creating": status_counts.get(VMStatus.CREATING, 0), + "error": status_counts.get(VMStatus.ERROR, 0), + } + + +@router.get("/nodes/{node_id}", response_model=ProxmoxNodeResponse) +async def get_proxmox_node( + node_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """Get Proxmox node by ID""" + node = db.query(ProxmoxNode).filter(ProxmoxNode.id == node_id).first() + if not node: + raise HTTPException(status_code=404, detail="Node not found") + return node + + +@router.get("/nodes/{node_id}/storage") +async def get_node_storage( + node_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """Get storage pools available on a node""" + node = db.query(ProxmoxNode).filter(ProxmoxNode.id == node_id).first() + if not node: + raise HTTPException(status_code=404, detail="Node not found") + + host = db.query(ProxmoxHost).filter(ProxmoxHost.id == node.host_id).first() + if not host: + raise HTTPException(status_code=404, detail="Host not found") + + try: + service = ProxmoxService(host) + storage_list = service.get_storage_list(node.node_name) + return {"storage": storage_list} + except Exception as e: + logger.error(f"Failed to get storage for node {node.node_name}: {e}") + raise HTTPException(status_code=500, detail=f"Failed to get storage: {str(e)}") + + +@router.get("/nodes/{node_id}/network") +async def get_node_network( + node_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """Get network interfaces/bridges available on a node""" + node = db.query(ProxmoxNode).filter(ProxmoxNode.id == node_id).first() + if not node: + raise HTTPException(status_code=404, detail="Node not found") + + host = db.query(ProxmoxHost).filter(ProxmoxHost.id == node.host_id).first() + if not host: + raise HTTPException(status_code=404, detail="Host not found") + + try: + service = ProxmoxService(host) + network_list = service.get_network_interfaces(node.node_name) + return {"network": network_list} + except Exception as e: + logger.error(f"Failed to get network for node {node.node_name}: {e}") + raise HTTPException(status_code=500, detail=f"Failed to get network: {str(e)}") diff --git a/src/backend/app/api/setup.py b/src/backend/app/api/setup.py new file mode 100644 index 0000000..c534309 --- /dev/null +++ b/src/backend/app/api/setup.py @@ -0,0 +1,319 @@ +"""Setup API endpoints for automated configuration""" +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel +from app.api.auth import get_current_user +from app.models import ProxmoxHost, ProxmoxNode +from app.core.database import get_db +from sqlalchemy.orm import Session +import logging +import subprocess +import os + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +class CloudImageSetupRequest(BaseModel): + proxmox_password: str + + +class ProxmoxClusterSSHRequest(BaseModel): + proxmox_password: str + + +@router.post("/cloud-images/enable") +def enable_cloud_images( + request: CloudImageSetupRequest, + db: Session = Depends(get_db), + current_user=Depends(get_current_user) +): + """ + Automatically enable cloud images by running the setup script + This sets up SSH access to Proxmox for cloud image template creation + """ + try: + # Get Proxmox host + host = db.query(ProxmoxHost).first() + if not host: + raise HTTPException( + status_code=404, + detail="No Proxmox host configured. Please add a Proxmox host first." + ) + + proxmox_host = host.hostname + password = request.proxmox_password + + if not password: + raise HTTPException( + status_code=400, + detail="Proxmox root password is required" + ) + + logger.info(f"Starting cloud image setup for host {proxmox_host}") + + # Check if SSH is already configured + check_ssh = subprocess.run( + [ + 'sudo', '-u', 'depl0y', + 'ssh', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=5', + '-o', 'StrictHostKeyChecking=no', + f'root@{proxmox_host}', + 'echo test' + ], + capture_output=True, + text=True, + timeout=10 + ) + + if check_ssh.returncode == 0: + logger.info("SSH already configured") + return { + "success": True, + "already_configured": True, + "message": "SSH access is already configured! Cloud images are ready to use." + } + + # Install sshpass if not present + logger.info("Checking for sshpass") + check_sshpass = subprocess.run( + ['which', 'sshpass'], + capture_output=True + ) + + if check_sshpass.returncode != 0: + logger.info("Installing sshpass") + install_result = subprocess.run( + [ + 'sudo', 'DEBIAN_FRONTEND=noninteractive', + 'apt-get', 'install', '-y', '-qq', 'sshpass' + ], + capture_output=True, + text=True, + timeout=60 + ) + if install_result.returncode != 0: + raise Exception(f"Failed to install sshpass: {install_result.stderr}") + + # Generate SSH key if it doesn't exist + ssh_key_path = '/opt/depl0y/.ssh/id_rsa' + if not os.path.exists(ssh_key_path): + logger.info("Generating SSH key") + subprocess.run( + [ + 'sudo', '-u', 'depl0y', + 'mkdir', '-p', '/opt/depl0y/.ssh' + ], + check=True + ) + subprocess.run( + [ + 'sudo', '-u', 'depl0y', + 'ssh-keygen', '-t', 'rsa', '-b', '4096', + '-f', ssh_key_path, + '-N', '', '-q' + ], + check=True + ) + + # Copy SSH key to Proxmox using sshpass + logger.info(f"Copying SSH key to {proxmox_host}") + copy_result = subprocess.run( + [ + 'sudo', '-u', 'depl0y', + 'sshpass', '-p', password, + 'ssh-copy-id', + '-o', 'StrictHostKeyChecking=no', + '-i', f'{ssh_key_path}.pub', + f'root@{proxmox_host}' + ], + capture_output=True, + text=True, + timeout=30 + ) + + if copy_result.returncode != 0: + # Try alternative method + logger.info("Trying alternative SSH key copy method") + with open(f'{ssh_key_path}.pub', 'r') as f: + public_key = f.read().strip() + + alt_result = subprocess.run( + [ + 'sudo', '-u', 'depl0y', + 'sshpass', '-p', password, + 'ssh', '-o', 'StrictHostKeyChecking=no', + f'root@{proxmox_host}', + f"mkdir -p ~/.ssh && chmod 700 ~/.ssh && echo '{public_key}' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys && sort -u ~/.ssh/authorized_keys -o ~/.ssh/authorized_keys" + ], + capture_output=True, + text=True, + timeout=30 + ) + + if alt_result.returncode != 0: + raise Exception(f"Failed to copy SSH key: {alt_result.stderr}") + + # Verify SSH access works + logger.info("Verifying SSH access") + verify_result = subprocess.run( + [ + 'sudo', '-u', 'depl0y', + 'ssh', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=5', + '-o', 'StrictHostKeyChecking=no', + f'root@{proxmox_host}', + 'echo SSH_CONFIGURED' + ], + capture_output=True, + text=True, + timeout=10 + ) + + if verify_result.returncode != 0 or 'SSH_CONFIGURED' not in verify_result.stdout: + raise Exception(f"SSH verification failed: {verify_result.stderr}") + + logger.info("Cloud image setup completed successfully") + + return { + "success": True, + "already_configured": False, + "message": "SSH access configured successfully! Cloud images are now enabled.", + "details": { + "host": proxmox_host, + "ssh_key": f"{ssh_key_path}.pub" + } + } + + except subprocess.TimeoutExpired: + logger.error("Setup timed out") + raise HTTPException( + status_code=408, + detail="Setup operation timed out. Please check network connectivity to Proxmox." + ) + except HTTPException: + raise + except Exception as e: + logger.error(f"Cloud image setup failed: {e}") + raise HTTPException( + status_code=500, + detail=f"Failed to enable cloud images: {str(e)}" + ) + + +@router.post("/proxmox-cluster-ssh/enable") +def enable_proxmox_cluster_ssh( + request: ProxmoxClusterSSHRequest, + db: Session = Depends(get_db), + current_user=Depends(get_current_user) +): + """ + Set up SSH access between Proxmox cluster nodes + This allows the deployment system to target specific nodes for template creation + """ + try: + # Get Proxmox host + host = db.query(ProxmoxHost).first() + if not host: + raise HTTPException( + status_code=404, + detail="No Proxmox host configured. Please add a Proxmox host first." + ) + + # Get all nodes + nodes = db.query(ProxmoxNode).filter(ProxmoxNode.host_id == host.id).all() + if len(nodes) < 2: + return { + "success": True, + "already_configured": True, + "message": "Only one node detected - inter-node SSH not needed for single node setups." + } + + proxmox_host = host.hostname + password = request.proxmox_password + + if not password: + raise HTTPException( + status_code=400, + detail="Proxmox root password is required" + ) + + logger.info(f"Starting inter-node SSH setup for Proxmox cluster") + + # Check if inter-node SSH is already working + node_names = [n.node_name for n in nodes] + test_node_1 = node_names[0] + test_node_2 = node_names[1] if len(node_names) > 1 else node_names[0] + + check_cmd = f"sudo -u depl0y ssh -o BatchMode=yes -o ConnectTimeout=5 -o StrictHostKeyChecking=no root@{proxmox_host} 'ssh -o BatchMode=yes -o ConnectTimeout=5 -o StrictHostKeyChecking=no {test_node_2} echo test' 2>&1" + check_result = subprocess.run(check_cmd, shell=True, capture_output=True, text=True, timeout=15) + + if check_result.returncode == 0 and 'test' in check_result.stdout: + logger.info("Inter-node SSH already configured") + return { + "success": True, + "already_configured": True, + "message": f"Inter-node SSH is already configured! Nodes can communicate with each other." + } + + # Set up SSH keys on Proxmox cluster nodes + logger.info("Setting up SSH keys between nodes...") + + setup_script = f""" +# Generate SSH key on first node if it doesn't exist +if [ ! -f ~/.ssh/id_rsa ]; then + ssh-keygen -t rsa -b 4096 -f ~/.ssh/id_rsa -N '' -q +fi + +# Get the public key +PUB_KEY=$(cat ~/.ssh/id_rsa.pub) + +# Copy key to all other nodes +{' '.join([f'sshpass -p "{password}" ssh-copy-id -o StrictHostKeyChecking=no -i ~/.ssh/id_rsa.pub root@{node} 2>/dev/null || sshpass -p "{password}" ssh -o StrictHostKeyChecking=no root@{node} "mkdir -p ~/.ssh && chmod 700 ~/.ssh && echo \\"$PUB_KEY\\" >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys && sort -u ~/.ssh/authorized_keys -o ~/.ssh/authorized_keys"' for node in node_names])} + +# Also set up reverse keys (each node can SSH to others) +{' '.join([f'ssh -o StrictHostKeyChecking=no root@{node} "ssh-keygen -t rsa -b 4096 -f ~/.ssh/id_rsa -N \\"\\" -q 2>/dev/null || true; ssh-copy-id -o StrictHostKeyChecking=no -i ~/.ssh/id_rsa.pub root@{proxmox_host} 2>/dev/null || true"' for node in node_names])} + +echo "SSH_SETUP_COMPLETE" +""" + + # Execute setup via SSH to Proxmox + cmd = f"sudo -u depl0y sshpass -p '{password}' ssh -o StrictHostKeyChecking=no root@{proxmox_host} '{setup_script}'" + result = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=60) + + if result.returncode != 0: + raise Exception(f"Failed to setup inter-node SSH: {result.stderr}") + + # Verify connectivity + logger.info("Verifying inter-node SSH connectivity...") + verify_cmd = f"sudo -u depl0y ssh -o BatchMode=yes -o ConnectTimeout=5 -o StrictHostKeyChecking=no root@{proxmox_host} 'ssh -o BatchMode=yes -o ConnectTimeout=5 -o StrictHostKeyChecking=no {test_node_2} echo VERIFIED'" + verify_result = subprocess.run(verify_cmd, shell=True, capture_output=True, text=True, timeout=15) + + if verify_result.returncode != 0 or 'VERIFIED' not in verify_result.stdout: + raise Exception(f"Inter-node SSH verification failed") + + logger.info("Inter-node SSH setup completed successfully") + + return { + "success": True, + "already_configured": False, + "message": f"Inter-node SSH configured successfully! All {len(nodes)} nodes can now communicate.", + "details": { + "nodes": node_names, + "tested": f"{test_node_1} → {test_node_2}" + } + } + + except subprocess.TimeoutExpired: + logger.error("Setup timed out") + raise HTTPException( + status_code=408, + detail="Setup operation timed out. Please check network connectivity." + ) + except HTTPException: + raise + except Exception as e: + logger.error(f"Inter-node SSH setup failed: {e}") + raise HTTPException( + status_code=500, + detail=f"Failed to enable inter-node SSH: {str(e)}" + ) diff --git a/src/backend/app/api/system.py b/src/backend/app/api/system.py new file mode 100644 index 0000000..16f4caa --- /dev/null +++ b/src/backend/app/api/system.py @@ -0,0 +1,69 @@ +"""System information API endpoints""" +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session +from app.core.database import get_db +from app.models.database import SystemSettings +from typing import Dict +import logging + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +@router.get("/info") +def get_system_info(db: Session = Depends(get_db)) -> Dict[str, str]: + """Get system information including version""" + try: + # Get version from database + version_setting = db.query(SystemSettings).filter(SystemSettings.key == "app_version").first() + app_name_setting = db.query(SystemSettings).filter(SystemSettings.key == "app_name").first() + + version = version_setting.value if version_setting else "1.1.3" + app_name = app_name_setting.value if app_name_setting else "Depl0y" + + return { + "version": version, + "app_name": app_name, + "status": "running" + } + except Exception as e: + logger.error(f"Failed to get system info: {e}") + # Fallback to hardcoded version if database query fails + return { + "version": "1.1.3", + "app_name": "Depl0y", + "status": "running" + } + + +@router.put("/version") +def update_version(new_version: str, db: Session = Depends(get_db)) -> Dict[str, str]: + """Update system version (admin only)""" + try: + version_setting = db.query(SystemSettings).filter(SystemSettings.key == "app_version").first() + + if version_setting: + version_setting.value = new_version + else: + version_setting = SystemSettings( + key="app_version", + value=new_version, + description="Current application version" + ) + db.add(version_setting) + + db.commit() + + return { + "success": True, + "version": new_version, + "message": "Version updated successfully" + } + except Exception as e: + logger.error(f"Failed to update version: {e}") + db.rollback() + return { + "success": False, + "message": str(e) + } diff --git a/src/backend/app/api/system_updates.py b/src/backend/app/api/system_updates.py new file mode 100644 index 0000000..b216bce --- /dev/null +++ b/src/backend/app/api/system_updates.py @@ -0,0 +1,194 @@ +"""System update API endpoints""" +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import FileResponse +from pydantic import BaseModel +from typing import Optional +from app.api.auth import get_current_user +from app.core.config import settings +import logging +import subprocess +import os +import requests + +logger = logging.getLogger(__name__) + +router = APIRouter() + +# Update server configuration +UPDATE_SERVER = "http://deploy.agit8or.net" +UPDATE_ENDPOINT = f"{UPDATE_SERVER}/api/v1/system-updates/version" + + +class UpdateCheckResponse(BaseModel): + current_version: str + latest_version: str + update_available: bool + download_url: Optional[str] = None + release_notes: Optional[str] = None + + +@router.get("/check") +def check_for_updates(current_user=Depends(get_current_user)): + """Check if updates are available from the main server""" + try: + # Get current version + current_version = settings.APP_VERSION + + # Query update server for latest version + try: + response = requests.get(UPDATE_ENDPOINT, timeout=10) + if response.status_code == 200: + update_info = response.json() + latest_version = update_info.get("version", current_version) + + # Simple version comparison (assumes semantic versioning) + update_available = latest_version != current_version + + return UpdateCheckResponse( + current_version=current_version, + latest_version=latest_version, + update_available=update_available, + download_url=update_info.get("download_url") if update_available else None, + release_notes=update_info.get("release_notes") + ) + else: + raise Exception(f"Update server returned {response.status_code}") + except requests.RequestException as e: + logger.warning(f"Could not reach update server: {e}") + return UpdateCheckResponse( + current_version=current_version, + latest_version=current_version, + update_available=False, + release_notes="Could not reach update server" + ) + + except Exception as e: + logger.error(f"Failed to check for updates: {e}") + raise HTTPException( + status_code=500, + detail=f"Failed to check for updates: {str(e)}" + ) + + +@router.get("/version") +def get_version_info(): + """ + Serve version information for update clients + This endpoint is called BY OTHER INSTANCES to check for updates + """ + return { + "version": settings.APP_VERSION, + "download_url": f"{UPDATE_SERVER}/api/v1/system-updates/download", + "install_url": f"{UPDATE_SERVER}/install.sh", + "release_notes": f""" +Depl0y {settings.APP_VERSION} Release Notes: + +✨ New in v1.1.9: +- Automatic backend restart after upgrades +- Updates now apply immediately without manual intervention +- Backend automatically loads new code after update completes + +✨ New in v1.1.8: +- Fixed JavaScript error preventing Settings page from loading +- Fixed installer version mismatch +- Updates work correctly in one step + +✨ Previous versions: +- One-click automatic updates from Settings (v1.1.5) +- Fast 30-second upgrades with pre-built frontend (v1.1.5) +- System Updates section with loading/error states (v1.1.7) +- Automated cloud image deployment +- Inter-node SSH setup for clusters + """.strip() + } + + +@router.get("/download") +def download_update(): + """Download the latest update package (public endpoint for automated updates)""" + try: + # Use pre-packaged file + package_path = "/opt/depl0y/depl0y-v1.1.5.tar.gz" + + if not os.path.exists(package_path): + # Fallback: create package on-the-fly + temp_package = "/tmp/depl0y-update.tar.gz" + create_package_cmd = f""" +cd /home/administrator/depl0y && \ +tar -czf {temp_package} \ + --exclude='node_modules' \ + --exclude='dist' \ + --exclude='.git' \ + --exclude='__pycache__' \ + --exclude='*.pyc' \ + --exclude='venv' \ + backend/ frontend/ *.md *.sh 2>/dev/null || true +""" + subprocess.run(create_package_cmd, shell=True, check=True) + package_path = temp_package + + if not os.path.exists(package_path): + raise HTTPException(status_code=500, detail="Failed to find or create update package") + + return FileResponse( + package_path, + media_type="application/gzip", + filename="depl0y-latest.tar.gz", + headers={ + "Cache-Control": "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0", + "Pragma": "no-cache", + "Expires": "0" + } + ) + + except Exception as e: + logger.error(f"Failed to serve update package: {e}") + raise HTTPException( + status_code=500, + detail=f"Failed to serve update package: {str(e)}" + ) + + +@router.post("/apply") +def apply_update(current_user=Depends(get_current_user)): + """Apply update by downloading and running the installer script""" + try: + logger.info("Starting update process via installer...") + + # Download installer script + installer_url = f"{UPDATE_SERVER}/downloads/install.sh" + installer_path = "/tmp/depl0y-update-install.sh" + + logger.info(f"Downloading installer from {installer_url}") + + response = requests.get(installer_url, timeout=30) + if response.status_code != 200: + raise Exception(f"Failed to download installer: HTTP {response.status_code}") + + # Save installer + with open(installer_path, 'wb') as f: + f.write(response.content) + + # Make executable + os.chmod(installer_path, 0o755) + + logger.info("Installer downloaded, starting update in background...") + + # Run installer in background (it will detect existing installation and upgrade) + update_script = f"/usr/bin/sudo /opt/depl0y/scripts/update-wrapper.sh {installer_path}" + subprocess.Popen(update_script, shell=True) + + return { + "success": True, + "message": "Update is being applied. The installer will upgrade your installation while preserving your data. The service will restart automatically.", + "log_file": "/tmp/depl0y-update.log" + } + + except Exception as e: + logger.error(f"Failed to start update: {e}") + raise HTTPException( + status_code=500, + detail=f"Failed to start update: {str(e)}" + ) + + diff --git a/src/backend/app/api/updates.py b/src/backend/app/api/updates.py new file mode 100644 index 0000000..8539ac0 --- /dev/null +++ b/src/backend/app/api/updates.py @@ -0,0 +1,112 @@ +"""Updates API routes""" +from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks +from sqlalchemy.orm import Session +from pydantic import BaseModel +from typing import List, Optional +from datetime import datetime + +from app.core.database import get_db +from app.models import UpdateLog, User +from app.api.auth import get_current_user, require_operator +from app.services.updates import UpdateService + +router = APIRouter() + + +# Pydantic models +class UpdateLogResponse(BaseModel): + id: int + vm_id: int + initiated_by: int + status: str + packages_updated: int + output: Optional[str] + error_message: Optional[str] + started_at: datetime + completed_at: Optional[datetime] + + class Config: + from_attributes = True + + +@router.post("/vm/{vm_id}/check") +async def check_vm_updates( + vm_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """Check for available updates on a VM""" + update_service = UpdateService(db) + result = update_service.check_updates(vm_id) + + if not result: + raise HTTPException(status_code=500, detail="Failed to check updates") + + return result + + +@router.post("/vm/{vm_id}/install") +async def install_vm_updates( + vm_id: int, + background_tasks: BackgroundTasks, + current_user: User = Depends(require_operator), + db: Session = Depends(get_db), +): + """Install updates on a VM""" + update_service = UpdateService(db) + + # Run update in background + background_tasks.add_task(update_service.install_updates, vm_id, current_user.id) + + return {"status": "started", "message": "Update process started"} + + +@router.get("/vm/{vm_id}/history", response_model=List[UpdateLogResponse]) +async def get_vm_update_history( + vm_id: int, + skip: int = 0, + limit: int = 100, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """Get update history for a VM""" + logs = ( + db.query(UpdateLog) + .filter(UpdateLog.vm_id == vm_id) + .order_by(UpdateLog.started_at.desc()) + .offset(skip) + .limit(limit) + .all() + ) + + return logs + + +@router.get("/log/{log_id}", response_model=UpdateLogResponse) +async def get_update_log( + log_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """Get update log by ID""" + log = db.query(UpdateLog).filter(UpdateLog.id == log_id).first() + if not log: + raise HTTPException(status_code=404, detail="Update log not found") + + return log + + +@router.post("/vm/{vm_id}/install-qemu-agent") +async def install_vm_qemu_agent( + vm_id: int, + current_user: User = Depends(require_operator), + db: Session = Depends(get_db), +): + """Install QEMU guest agent on a VM""" + update_service = UpdateService(db) + success = update_service.install_qemu_agent(vm_id) + + if success: + return {"status": "success", "message": "QEMU guest agent installed"} + else: + raise HTTPException(status_code=500, detail="Failed to install QEMU guest agent") diff --git a/src/backend/app/api/users.py b/src/backend/app/api/users.py new file mode 100644 index 0000000..f07ea80 --- /dev/null +++ b/src/backend/app/api/users.py @@ -0,0 +1,187 @@ +"""Users API routes""" +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session +from pydantic import BaseModel, EmailStr +from typing import List, Optional +from datetime import datetime + +from app.core.database import get_db +from app.core.security import get_password_hash, verify_password +from app.models import User, UserRole +from app.api.auth import get_current_user, require_admin + +router = APIRouter() + + +# Pydantic models +class UserCreate(BaseModel): + username: str + email: EmailStr + password: str + role: UserRole = UserRole.VIEWER + + +class UserUpdate(BaseModel): + email: Optional[EmailStr] = None + role: Optional[UserRole] = None + is_active: Optional[bool] = None + + +class UserPasswordChange(BaseModel): + current_password: str + new_password: str + + +class UserResponse(BaseModel): + id: int + username: str + email: str + role: UserRole + is_active: bool + totp_enabled: bool + created_at: datetime + last_login: Optional[datetime] + + class Config: + from_attributes = True + + +@router.get("/", response_model=List[UserResponse]) +async def list_users( + skip: int = 0, + limit: int = 100, + current_user: User = Depends(require_admin), + db: Session = Depends(get_db), +): + """List all users (admin only)""" + users = db.query(User).offset(skip).limit(limit).all() + return users + + +@router.post("/", response_model=UserResponse, status_code=status.HTTP_201_CREATED) +async def create_user( + user_data: UserCreate, + current_user: User = Depends(require_admin), + db: Session = Depends(get_db), +): + """Create a new user (admin only)""" + # Check if username already exists + existing_user = db.query(User).filter(User.username == user_data.username).first() + if existing_user: + raise HTTPException(status_code=400, detail="Username already exists") + + # Check if email already exists + existing_email = db.query(User).filter(User.email == user_data.email).first() + if existing_email: + raise HTTPException(status_code=400, detail="Email already exists") + + # Create new user + hashed_password = get_password_hash(user_data.password) + new_user = User( + username=user_data.username, + email=user_data.email, + hashed_password=hashed_password, + role=user_data.role, + ) + + db.add(new_user) + db.commit() + db.refresh(new_user) + + return new_user + + +@router.get("/{user_id}", response_model=UserResponse) +async def get_user( + user_id: int, + current_user: User = Depends(require_admin), + db: Session = Depends(get_db), +): + """Get user by ID (admin only)""" + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + return user + + +@router.put("/{user_id}", response_model=UserResponse) +async def update_user( + user_id: int, + user_data: UserUpdate, + current_user: User = Depends(require_admin), + db: Session = Depends(get_db), +): + """Update user (admin only)""" + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + # Prevent admin from disabling themselves + if user.id == current_user.id and user_data.is_active is False: + raise HTTPException( + status_code=400, detail="Cannot deactivate your own account" + ) + + # Update fields + if user_data.email is not None: + # Check if email already exists + existing_email = ( + db.query(User) + .filter(User.email == user_data.email, User.id != user_id) + .first() + ) + if existing_email: + raise HTTPException(status_code=400, detail="Email already exists") + user.email = user_data.email + + if user_data.role is not None: + user.role = user_data.role + + if user_data.is_active is not None: + user.is_active = user_data.is_active + + user.updated_at = datetime.utcnow() + db.commit() + db.refresh(user) + + return user + + +@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_user( + user_id: int, + current_user: User = Depends(require_admin), + db: Session = Depends(get_db), +): + """Delete user (admin only)""" + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + # Prevent admin from deleting themselves + if user.id == current_user.id: + raise HTTPException(status_code=400, detail="Cannot delete your own account") + + db.delete(user) + db.commit() + + return None + + +@router.post("/change-password") +async def change_password( + password_data: UserPasswordChange, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """Change current user's password""" + # Verify current password + if not verify_password(password_data.current_password, current_user.hashed_password): + raise HTTPException(status_code=400, detail="Incorrect current password") + + # Update password + current_user.hashed_password = get_password_hash(password_data.new_password) + current_user.updated_at = datetime.utcnow() + db.commit() + + return {"message": "Password changed successfully"} diff --git a/src/backend/app/api/vms.py b/src/backend/app/api/vms.py new file mode 100644 index 0000000..0483033 --- /dev/null +++ b/src/backend/app/api/vms.py @@ -0,0 +1,623 @@ +"""Virtual Machines API routes""" +from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks +from sqlalchemy.orm import Session +from pydantic import BaseModel, field_validator +from typing import List, Optional +from datetime import datetime + +from app.core.database import get_db +from app.core.security import encrypt_data +from app.models import VirtualMachine, VMStatus, OSType, User +from app.api.auth import get_current_user, require_operator +from app.services.deployment import DeploymentService + +router = APIRouter() + + +# Pydantic models +class VMCreate(BaseModel): + name: str + hostname: str + proxmox_host_id: int + node_id: int + iso_id: Optional[int] = None + cloud_image_id: Optional[int] = None # Use cloud image template instead of ISO + os_type: str # Accept any string, will be validated/converted internally + + # CPU options + cpu_sockets: int = 1 + cpu_cores: int + cpu_type: Optional[str] = "host" # host, qemu64, kvm64, etc. + cpu_flags: Optional[str] = None # Additional CPU flags + cpu_limit: Optional[int] = None # CPU usage limit (0 = unlimited) + cpu_units: int = 1024 # CPU weight for scheduler + numa_enabled: bool = False # NUMA support + + # Memory and disk + memory: int # MB + balloon: Optional[int] = None # Balloon device (0 = disabled, MB) + shares: Optional[int] = None # Memory shares for scheduler + disk_size: int # GB + storage: Optional[str] = None # Storage pool name for VM disks + iso_storage: Optional[str] = None # Storage pool name for ISO files + scsihw: str = "virtio-scsi-pci" # SCSI controller type + + # Hardware options + bios_type: str = "seabios" # seabios or ovmf (UEFI) + machine_type: Optional[str] = "pc" # pc, q35, etc. + vga_type: str = "std" # std, virtio, qxl, vmware, cirrus + boot_order: str = "cdn" # c=disk, d=cdrom, n=network + onboot: bool = True # Start VM at boot + tablet: bool = True # Enable tablet pointer device + hotplug: Optional[str] = None # Hotplug options: disk,network,usb,memory,cpu + protection: bool = False # Prevent accidental deletion + startup_order: Optional[int] = None # Startup order + startup_up: Optional[int] = None # Startup delay in seconds + startup_down: Optional[int] = None # Shutdown timeout in seconds + kvm: bool = True # Enable KVM hardware virtualization + acpi: bool = True # Enable ACPI + agent_enabled: bool = True # QEMU guest agent + description: Optional[str] = None # VM description + tags: Optional[str] = None # VM tags (semicolon-separated) + + # Network configuration + network_bridge: Optional[str] = None # Primary network bridge + network_interfaces: Optional[list] = None # Additional network interfaces + ip_address: Optional[str] = None + gateway: Optional[str] = None + netmask: Optional[str] = None + dns_servers: Optional[str] = None + + # Credentials + username: str + password: str + ssh_key: Optional[str] = None + + +class VMUpdate(BaseModel): + name: Optional[str] = None + status: Optional[VMStatus] = None + ip_address: Optional[str] = None + + +class VMResponse(BaseModel): + id: int + vmid: Optional[int] + name: str + hostname: str + proxmox_host_id: int + node_id: Optional[int] + iso_id: Optional[int] + cloud_image_id: Optional[int] + os_type: str # Return as string for flexibility + cpu_cores: int + memory: int + disk_size: int + ip_address: Optional[str] + gateway: Optional[str] + netmask: Optional[str] + dns_servers: Optional[str] + username: str + status: str # Return as string for flexibility + error_message: Optional[str] + created_at: datetime + deployed_at: Optional[datetime] + created_by: int + + class Config: + from_attributes = True + + +class ProxmoxVMResponse(BaseModel): + """Response model for VMs queried directly from Proxmox""" + vmid: int + name: str + status: str + node: str + cpus: int + maxmem: int # bytes + maxdisk: int # bytes + + class Config: + from_attributes = True + + +@router.get("/", response_model=List[ProxmoxVMResponse]) +async def list_vms( + skip: int = 0, + limit: int = 1000, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """List all virtual machines from Proxmox""" + from app.services.proxmox import ProxmoxService + from app.models import ProxmoxHost + import logging + + logger = logging.getLogger(__name__) + + # Query all active Proxmox hosts and get VMs from Proxmox itself + active_hosts = db.query(ProxmoxHost).filter(ProxmoxHost.is_active == True).all() + + all_vms = [] + for host in active_hosts: + try: + service = ProxmoxService(host) + vms = service.get_all_vms() + + # Convert to response format + for vm in vms: + all_vms.append({ + 'vmid': vm.get('vmid'), + 'name': vm.get('name', f"VM {vm.get('vmid')}"), + 'status': vm.get('status', 'unknown'), + 'node': vm.get('node', 'unknown'), + 'cpus': vm.get('cpus', 0), + 'maxmem': vm.get('maxmem', 0), + 'maxdisk': vm.get('maxdisk', 0), + }) + except Exception as e: + logger.error(f"Failed to get VMs from host {host.name}: {e}") + + return all_vms + + +@router.post("/", response_model=VMResponse, status_code=status.HTTP_201_CREATED) +async def create_vm( + vm_data: VMCreate, + background_tasks: BackgroundTasks, + current_user: User = Depends(require_operator), + db: Session = Depends(get_db), +): + """Create and deploy a new virtual machine""" + import logging + logger = logging.getLogger(__name__) + logger.info(f"Received VM create request: {vm_data.model_dump()}") + + # Convert os_type string to enum + try: + os_type_enum = OSType(vm_data.os_type) + except ValueError: + # If not a valid enum value, default to OTHER + os_type_enum = OSType.OTHER + logger.warning(f"Unknown os_type '{vm_data.os_type}', defaulting to OTHER") + + # Create VM record + new_vm = VirtualMachine( + name=vm_data.name, + hostname=vm_data.hostname, + proxmox_host_id=vm_data.proxmox_host_id, + node_id=vm_data.node_id, + iso_id=vm_data.iso_id, + cloud_image_id=vm_data.cloud_image_id, + os_type=os_type_enum, + + # CPU options + cpu_sockets=vm_data.cpu_sockets, + cpu_cores=vm_data.cpu_cores, + cpu_type=vm_data.cpu_type, + cpu_flags=vm_data.cpu_flags, + cpu_limit=vm_data.cpu_limit, + cpu_units=vm_data.cpu_units, + numa_enabled=vm_data.numa_enabled, + + # Memory and disk + memory=vm_data.memory, + balloon=vm_data.balloon, + shares=vm_data.shares, + disk_size=vm_data.disk_size, + storage=vm_data.storage, + iso_storage=vm_data.iso_storage, + scsihw=vm_data.scsihw, + + # Hardware options + bios_type=vm_data.bios_type, + machine_type=vm_data.machine_type, + vga_type=vm_data.vga_type, + boot_order=vm_data.boot_order, + onboot=vm_data.onboot, + tablet=vm_data.tablet, + hotplug=vm_data.hotplug, + protection=vm_data.protection, + startup_order=vm_data.startup_order, + startup_up=vm_data.startup_up, + startup_down=vm_data.startup_down, + kvm=vm_data.kvm, + acpi=vm_data.acpi, + agent_enabled=vm_data.agent_enabled, + description=vm_data.description, + tags=vm_data.tags, + + # Network configuration + network_bridge=vm_data.network_bridge, + network_interfaces=vm_data.network_interfaces, + ip_address=vm_data.ip_address, + gateway=vm_data.gateway, + netmask=vm_data.netmask, + dns_servers=vm_data.dns_servers, + + # Credentials + username=vm_data.username, + password=vm_data.password, # Should be encrypted in production + ssh_key=vm_data.ssh_key, + + status=VMStatus.CREATING, + created_by=current_user.id, + ) + + db.add(new_vm) + db.commit() + db.refresh(new_vm) + + # Deploy VM in background + deployment_service = DeploymentService(db) + + # Determine deployment type based on OS + windows_types = [ + OSType.WINDOWS_SERVER_2016, + OSType.WINDOWS_SERVER_2019, + OSType.WINDOWS_SERVER_2022, + OSType.WINDOWS_10, + OSType.WINDOWS_11 + ] + + if os_type_enum in windows_types: + logger.info(f"Deploying Windows VM {new_vm.id} ({os_type_enum.value})") + background_tasks.add_task(deployment_service.deploy_windows_vm, new_vm.id) + else: + logger.info(f"Deploying Linux/Other VM {new_vm.id} ({os_type_enum.value})") + background_tasks.add_task(deployment_service.deploy_linux_vm, new_vm.id) + + return new_vm + + +@router.get("/{vm_id}", response_model=VMResponse) +async def get_vm( + vm_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """Get virtual machine by ID""" + vm = db.query(VirtualMachine).filter(VirtualMachine.id == vm_id).first() + if not vm: + raise HTTPException(status_code=404, detail="VM not found") + + # Check permissions + if current_user.role.value == "viewer" and vm.created_by != current_user.id: + raise HTTPException(status_code=403, detail="Not authorized to view this VM") + + return vm + + +@router.put("/{vm_id}", response_model=VMResponse) +async def update_vm( + vm_id: int, + vm_data: VMUpdate, + current_user: User = Depends(require_operator), + db: Session = Depends(get_db), +): + """Update virtual machine""" + vm = db.query(VirtualMachine).filter(VirtualMachine.id == vm_id).first() + if not vm: + raise HTTPException(status_code=404, detail="VM not found") + + # Update fields + if vm_data.name is not None: + vm.name = vm_data.name + + if vm_data.status is not None: + vm.status = vm_data.status + + if vm_data.ip_address is not None: + vm.ip_address = vm_data.ip_address + + vm.last_updated = datetime.utcnow() + db.commit() + db.refresh(vm) + + return vm + + +@router.delete("/{vm_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_vm( + vm_id: int, + current_user: User = Depends(require_operator), + db: Session = Depends(get_db), +): + """Delete virtual machine""" + vm = db.query(VirtualMachine).filter(VirtualMachine.id == vm_id).first() + if not vm: + raise HTTPException(status_code=404, detail="VM not found") + + # Delete from Proxmox and database + deployment_service = DeploymentService(db) + success = deployment_service.delete_vm(vm_id) + + if not success: + raise HTTPException(status_code=500, detail="Failed to delete VM") + + return None + + +@router.post("/{vm_id}/start") +async def start_vm( + vm_id: int, + current_user: User = Depends(require_operator), + db: Session = Depends(get_db), +): + """Start virtual machine""" + vm = db.query(VirtualMachine).filter(VirtualMachine.id == vm_id).first() + if not vm: + raise HTTPException(status_code=404, detail="VM not found") + + from app.services.proxmox import ProxmoxService + from app.models import ProxmoxHost, ProxmoxNode + + host = db.query(ProxmoxHost).filter(ProxmoxHost.id == vm.proxmox_host_id).first() + node = db.query(ProxmoxNode).filter(ProxmoxNode.id == vm.node_id).first() + + if not host or not node or not vm.vmid: + raise HTTPException(status_code=400, detail="VM not fully configured") + + proxmox = ProxmoxService(host) + success = proxmox.start_vm(node.node_name, vm.vmid) + + if success: + vm.status = VMStatus.RUNNING + db.commit() + return {"status": "success", "message": "VM started"} + else: + raise HTTPException(status_code=500, detail="Failed to start VM") + + +@router.post("/{vm_id}/stop") +async def stop_vm( + vm_id: int, + current_user: User = Depends(require_operator), + db: Session = Depends(get_db), +): + """Stop virtual machine""" + vm = db.query(VirtualMachine).filter(VirtualMachine.id == vm_id).first() + if not vm: + raise HTTPException(status_code=404, detail="VM not found") + + from app.services.proxmox import ProxmoxService + from app.models import ProxmoxHost, ProxmoxNode + + host = db.query(ProxmoxHost).filter(ProxmoxHost.id == vm.proxmox_host_id).first() + node = db.query(ProxmoxNode).filter(ProxmoxNode.id == vm.node_id).first() + + if not host or not node or not vm.vmid: + raise HTTPException(status_code=400, detail="VM not fully configured") + + proxmox = ProxmoxService(host) + success = proxmox.stop_vm(node.node_name, vm.vmid) + + if success: + vm.status = VMStatus.STOPPED + db.commit() + return {"status": "success", "message": "VM stopped"} + else: + raise HTTPException(status_code=500, detail="Failed to stop VM") + + +@router.get("/{vm_id}/status") +async def get_vm_status( + vm_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """Get VM status from Proxmox""" + deployment_service = DeploymentService(db) + status_info = deployment_service.check_vm_status(vm_id) + + if not status_info: + raise HTTPException(status_code=404, detail="Could not retrieve VM status") + + return status_info + + +@router.get("/{vm_id}/progress") +async def get_vm_progress( + vm_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + """Get VM deployment progress""" + vm = db.query(VirtualMachine).filter(VirtualMachine.id == vm_id).first() + if not vm: + raise HTTPException(status_code=404, detail="VM not found") + + return { + "id": vm.id, + "name": vm.name, + "status": vm.status.value, + "status_message": vm.status_message, + "error_message": vm.error_message, + "vmid": vm.vmid + } + + +@router.post("/control/{node_name}/{vmid}/start") +async def start_vm_by_vmid( + node_name: str, + vmid: int, + current_user: User = Depends(require_operator), + db: Session = Depends(get_db), +): + """Start a VM by VMID and node name""" + from app.services.proxmox import ProxmoxService + from app.models import ProxmoxHost + import logging + + logger = logging.getLogger(__name__) + + # Find the host that has this node + active_hosts = db.query(ProxmoxHost).filter(ProxmoxHost.is_active == True).all() + + for host in active_hosts: + try: + service = ProxmoxService(host) + nodes = service.get_nodes() + + # Check if this host has the requested node + if any(n.get('node') == node_name for n in nodes): + success = service.start_vm(node_name, vmid) + if success: + return {"status": "success", "message": f"VM {vmid} started"} + else: + raise HTTPException(status_code=500, detail="Failed to start VM") + except Exception as e: + logger.error(f"Error starting VM on host {host.name}: {e}") + continue + + raise HTTPException(status_code=404, detail="Node or VM not found") + + +@router.post("/control/{node_name}/{vmid}/stop") +async def stop_vm_by_vmid( + node_name: str, + vmid: int, + current_user: User = Depends(require_operator), + db: Session = Depends(get_db), +): + """Stop a VM by VMID and node name (graceful shutdown)""" + from app.services.proxmox import ProxmoxService + from app.models import ProxmoxHost + import logging + + logger = logging.getLogger(__name__) + + active_hosts = db.query(ProxmoxHost).filter(ProxmoxHost.is_active == True).all() + + for host in active_hosts: + try: + service = ProxmoxService(host) + nodes = service.get_nodes() + + if any(n.get('node') == node_name for n in nodes): + success = service.stop_vm(node_name, vmid) + if success: + return {"status": "success", "message": f"VM {vmid} stopped"} + else: + raise HTTPException(status_code=500, detail="Failed to stop VM") + except Exception as e: + logger.error(f"Error stopping VM on host {host.name}: {e}") + continue + + raise HTTPException(status_code=404, detail="Node or VM not found") + + +@router.post("/control/{node_name}/{vmid}/shutdown") +async def shutdown_vm_by_vmid( + node_name: str, + vmid: int, + current_user: User = Depends(require_operator), + db: Session = Depends(get_db), +): + """Shutdown a VM by VMID and node name (force power off)""" + from app.services.proxmox import ProxmoxService + from app.models import ProxmoxHost + import logging + + logger = logging.getLogger(__name__) + + active_hosts = db.query(ProxmoxHost).filter(ProxmoxHost.is_active == True).all() + + for host in active_hosts: + try: + service = ProxmoxService(host) + nodes = service.get_nodes() + + if any(n.get('node') == node_name for n in nodes): + success = service.shutdown_vm(node_name, vmid) + if success: + return {"status": "success", "message": f"VM {vmid} powered off"} + else: + raise HTTPException(status_code=500, detail="Failed to power off VM") + except Exception as e: + logger.error(f"Error powering off VM on host {host.name}: {e}") + continue + + raise HTTPException(status_code=404, detail="Node or VM not found") + + +@router.post("/control/{node_name}/{vmid}/restart") +async def restart_vm_by_vmid( + node_name: str, + vmid: int, + current_user: User = Depends(require_operator), + db: Session = Depends(get_db), +): + """Restart a VM by VMID and node name""" + from app.services.proxmox import ProxmoxService + from app.models import ProxmoxHost + import logging + + logger = logging.getLogger(__name__) + + active_hosts = db.query(ProxmoxHost).filter(ProxmoxHost.is_active == True).all() + + for host in active_hosts: + try: + service = ProxmoxService(host) + nodes = service.get_nodes() + + if any(n.get('node') == node_name for n in nodes): + success = service.restart_vm(node_name, vmid) + if success: + return {"status": "success", "message": f"VM {vmid} restarted"} + else: + raise HTTPException(status_code=500, detail="Failed to restart VM") + except Exception as e: + logger.error(f"Error restarting VM on host {host.name}: {e}") + continue + + raise HTTPException(status_code=404, detail="Node or VM not found") + + +@router.delete("/control/{node_name}/{vmid}/delete") +async def delete_vm_by_vmid( + node_name: str, + vmid: int, + current_user: User = Depends(require_operator), + db: Session = Depends(get_db), +): + """Delete a VM by VMID and node name - permanently removes from Proxmox""" + from app.services.proxmox import ProxmoxService + from app.models import ProxmoxHost + import logging + + logger = logging.getLogger(__name__) + logger.info(f"Delete request for VM {vmid} on node {node_name} by user {current_user.username}") + + active_hosts = db.query(ProxmoxHost).filter(ProxmoxHost.is_active == True).all() + + for host in active_hosts: + try: + service = ProxmoxService(host) + nodes = service.get_nodes() + + if any(n.get('node') == node_name for n in nodes): + # Delete VM from Proxmox + try: + service.proxmox.nodes(node_name).qemu(vmid).delete() + logger.info(f"Successfully deleted VM {vmid} from node {node_name}") + + # Also remove from database if it exists + vm_record = db.query(VirtualMachine).filter(VirtualMachine.vmid == vmid).first() + if vm_record: + db.delete(vm_record) + db.commit() + logger.info(f"Removed VM {vmid} record from database") + + return {"status": "success", "message": f"VM {vmid} deleted successfully"} + except Exception as e: + error_msg = str(e) + logger.error(f"Failed to delete VM {vmid}: {error_msg}") + raise HTTPException(status_code=500, detail=f"Failed to delete VM: {error_msg}") + except Exception as e: + logger.error(f"Error accessing host {host.name}: {e}") + continue + + raise HTTPException(status_code=404, detail="Node or VM not found") diff --git a/src/backend/app/core/__init__.py b/src/backend/app/core/__init__.py new file mode 100644 index 0000000..f5fd886 --- /dev/null +++ b/src/backend/app/core/__init__.py @@ -0,0 +1 @@ +"""Core application package""" diff --git a/src/backend/app/core/config.py b/src/backend/app/core/config.py new file mode 100644 index 0000000..6be3a23 --- /dev/null +++ b/src/backend/app/core/config.py @@ -0,0 +1,101 @@ +"""Application configuration""" +from pydantic_settings import BaseSettings +from typing import Optional +import os + + +def get_app_version(): + """Get application version from database or fallback to default""" + try: + import sqlite3 + db_path = os.getenv("DATABASE_URL", "sqlite:////var/lib/depl0y/db/depl0y.db") + # Extract path from sqlite URL + if db_path.startswith("sqlite:///"): + db_path = db_path.replace("sqlite:///", "") + + conn = sqlite3.connect(db_path) + cursor = conn.cursor() + cursor.execute("SELECT value FROM system_settings WHERE key = 'app_version'") + result = cursor.fetchone() + conn.close() + + if result: + return result[0] + except Exception as e: + # Fallback to hardcoded version if database query fails + pass + + return "1.1.9" + + +class Settings(BaseSettings): + """Application settings""" + + # Application + APP_NAME: str = "Depl0y" + APP_VERSION: str = get_app_version() + DEBUG: bool = False + + # API + API_V1_PREFIX: str = "/api/v1" + + # Security + SECRET_KEY: str = os.getenv("SECRET_KEY", "change-me-in-production-please-use-strong-secret") + ALGORITHM: str = "HS256" + ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 + REFRESH_TOKEN_EXPIRE_DAYS: int = 7 + + # Password hashing + PASSWORD_BCRYPT_ROUNDS: int = 12 + + # Database + DATABASE_URL: str = os.getenv( + "DATABASE_URL", + "sqlite:////var/lib/depl0y/db/depl0y.db" + ) + + # CORS + BACKEND_CORS_ORIGINS: list = [ + "http://localhost:3000", + "http://localhost:8080", + "http://127.0.0.1:3000", + "http://127.0.0.1:8080", + ] + + # ISO Storage + ISO_STORAGE_PATH: str = os.getenv("ISO_STORAGE_PATH", "/var/lib/depl0y/isos") + MAX_ISO_SIZE: int = 10 * 1024 * 1024 * 1024 # 10GB + + # Upload directory for cloud images and other files + UPLOAD_DIR: str = os.getenv("UPLOAD_DIR", "/var/lib/depl0y") + + # Proxmox polling + PROXMOX_POLL_INTERVAL: int = 60 # seconds + + # Encryption key for sensitive data + ENCRYPTION_KEY: Optional[str] = os.getenv("ENCRYPTION_KEY") + + # Cloud-init + CLOUDINIT_TEMPLATE_PATH: str = os.getenv( + "CLOUDINIT_TEMPLATE_PATH", + "/var/lib/depl0y/cloud-init" + ) + + # SSH + SSH_TIMEOUT: int = 30 + SSH_KEY_PATH: str = os.getenv("SSH_KEY_PATH", "/var/lib/depl0y/ssh_keys") + + # Default VM settings + DEFAULT_QEMU_AGENT_INSTALL: bool = True + DEFAULT_LINUX_PARTITION_SCHEME: str = "single" # single or custom + + # Logging + LOG_LEVEL: str = os.getenv("LOG_LEVEL", "INFO") + LOG_FILE: str = os.getenv("LOG_FILE", "/var/log/depl0y/app.log") + + class Config: + case_sensitive = True + env_file = ".env" + + +settings = Settings() diff --git a/src/backend/app/core/database.py b/src/backend/app/core/database.py new file mode 100644 index 0000000..e10e53c --- /dev/null +++ b/src/backend/app/core/database.py @@ -0,0 +1,34 @@ +"""Database connection and session management""" +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from app.core.config import settings + +# Create database engine +engine = create_engine( + settings.DATABASE_URL, + pool_pre_ping=True, + pool_size=10, + max_overflow=20, +) + +# Create session factory +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + + +def get_db(): + """ + Dependency to get database session + """ + db = SessionLocal() + try: + yield db + finally: + db.close() + + +def init_db(): + """ + Initialize database tables + """ + from app.models.database import Base + Base.metadata.create_all(bind=engine) diff --git a/src/backend/app/core/security.py b/src/backend/app/core/security.py new file mode 100644 index 0000000..eab5505 --- /dev/null +++ b/src/backend/app/core/security.py @@ -0,0 +1,95 @@ +"""Security utilities for authentication and encryption""" +from datetime import datetime, timedelta +from typing import Optional, Union +from jose import JWTError, jwt +import bcrypt +import pyotp +import secrets +from cryptography.fernet import Fernet +from app.core.config import settings + +# Encryption for sensitive data +cipher_suite = None +if settings.ENCRYPTION_KEY: + cipher_suite = Fernet(settings.ENCRYPTION_KEY.encode()) + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + """Verify a password against a hash""" + return bcrypt.checkpw(plain_password.encode('utf-8'), hashed_password.encode('utf-8')) + + +def get_password_hash(password: str) -> str: + """Generate password hash""" + salt = bcrypt.gensalt(rounds=12) + hashed = bcrypt.hashpw(password.encode('utf-8'), salt) + return hashed.decode('utf-8') + + +def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str: + """Create JWT access token""" + to_encode = data.copy() + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) + + to_encode.update({"exp": expire, "type": "access"}) + encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) + return encoded_jwt + + +def create_refresh_token(data: dict) -> str: + """Create JWT refresh token""" + to_encode = data.copy() + expire = datetime.utcnow() + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS) + to_encode.update({"exp": expire, "type": "refresh"}) + encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) + return encoded_jwt + + +def decode_token(token: str) -> Optional[dict]: + """Decode and verify JWT token""" + try: + payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]) + return payload + except JWTError: + return None + + +# TOTP (2FA) functions +def generate_totp_secret() -> str: + """Generate a new TOTP secret""" + return pyotp.random_base32() + + +def generate_totp_uri(secret: str, username: str, issuer: str = "Depl0y") -> str: + """Generate TOTP provisioning URI for QR code""" + totp = pyotp.TOTP(secret) + return totp.provisioning_uri(name=username, issuer_name=issuer) + + +def verify_totp_code(secret: str, code: str) -> bool: + """Verify TOTP code""" + totp = pyotp.TOTP(secret) + return totp.verify(code, valid_window=1) + + +# Encryption functions for sensitive data +def encrypt_data(data: str) -> str: + """Encrypt sensitive data""" + if not cipher_suite: + raise ValueError("ENCRYPTION_KEY not configured") + return cipher_suite.encrypt(data.encode()).decode() + + +def decrypt_data(encrypted_data: str) -> str: + """Decrypt sensitive data""" + if not cipher_suite: + raise ValueError("ENCRYPTION_KEY not configured") + return cipher_suite.decrypt(encrypted_data.encode()).decode() + + +def generate_api_key() -> str: + """Generate a secure API key""" + return secrets.token_urlsafe(32) diff --git a/src/backend/app/main.py b/src/backend/app/main.py new file mode 100644 index 0000000..c635fc6 --- /dev/null +++ b/src/backend/app/main.py @@ -0,0 +1,110 @@ +"""Main FastAPI application""" +from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse +from app.core.config import settings +from app.core.database import init_db +from app.api import auth, users, proxmox, vms, isos, cloud_images, updates, dashboard, bug_report, logs, docs, setup, system_updates, ha, system +import logging +from logging.handlers import RotatingFileHandler +import os + +# Configure logging +os.makedirs(os.path.dirname(settings.LOG_FILE), exist_ok=True) +logging.basicConfig( + level=settings.LOG_LEVEL, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + handlers=[ + RotatingFileHandler(settings.LOG_FILE, maxBytes=10485760, backupCount=5), + logging.StreamHandler(), + ], +) + +logger = logging.getLogger(__name__) + +# Create FastAPI app +app = FastAPI( + title=settings.APP_NAME, + version=settings.APP_VERSION, + description="Automated VM Deployment Panel for Proxmox VE", +) + +# Configure CORS +app.add_middleware( + CORSMiddleware, + allow_origins=settings.BACKEND_CORS_ORIGINS, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +# Add validation error handler for debugging +@app.exception_handler(RequestValidationError) +async def validation_exception_handler(request: Request, exc: RequestValidationError): + """Log validation errors with details""" + logger.error(f"Validation error on {request.method} {request.url}") + logger.error(f"Validation errors: {exc.errors()}") + try: + body = await request.body() + logger.error(f"Request body: {body.decode()}") + except: + pass + return JSONResponse( + status_code=422, + content={"detail": exc.errors()}, + ) + + +@app.on_event("startup") +async def startup_event(): + """Initialize database on startup""" + logger.info(f"Starting {settings.APP_NAME} v{settings.APP_VERSION}") + init_db() + logger.info("Database initialized") + + +@app.get("/") +async def root(): + """Root endpoint""" + return { + "name": settings.APP_NAME, + "version": settings.APP_VERSION, + "status": "running", + } + + +@app.get("/health") +async def health_check(): + """Health check endpoint""" + return {"status": "healthy"} + + +# Include routers +app.include_router(auth.router, prefix=f"{settings.API_V1_PREFIX}/auth", tags=["Authentication"]) +app.include_router(users.router, prefix=f"{settings.API_V1_PREFIX}/users", tags=["Users"]) +app.include_router(proxmox.router, prefix=f"{settings.API_V1_PREFIX}/proxmox", tags=["Proxmox"]) +app.include_router(vms.router, prefix=f"{settings.API_V1_PREFIX}/vms", tags=["Virtual Machines"]) +app.include_router(isos.router, prefix=f"{settings.API_V1_PREFIX}/isos", tags=["ISO Images"]) +app.include_router(cloud_images.router, prefix=f"{settings.API_V1_PREFIX}/cloud-images", tags=["Cloud Images"]) +app.include_router(updates.router, prefix=f"{settings.API_V1_PREFIX}/updates", tags=["Updates"]) +app.include_router(dashboard.router, prefix=f"{settings.API_V1_PREFIX}/dashboard", tags=["Dashboard"]) +app.include_router(bug_report.router, prefix=f"{settings.API_V1_PREFIX}/bug-report", tags=["Bug Report"]) +app.include_router(logs.router, prefix=f"{settings.API_V1_PREFIX}/logs", tags=["System Logs"]) +app.include_router(docs.router, prefix=f"{settings.API_V1_PREFIX}/docs", tags=["Documentation"]) +app.include_router(setup.router, prefix=f"{settings.API_V1_PREFIX}/setup", tags=["Setup"]) +app.include_router(system_updates.router, prefix=f"{settings.API_V1_PREFIX}/system-updates", tags=["System Updates"]) +app.include_router(ha.router, prefix=f"{settings.API_V1_PREFIX}/ha", tags=["High Availability"]) +app.include_router(system.router, prefix=f"{settings.API_V1_PREFIX}/system", tags=["System"]) + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run( + "app.main:app", + host="0.0.0.0", + port=8000, + reload=settings.DEBUG, + ) diff --git a/src/backend/app/models/__init__.py b/src/backend/app/models/__init__.py new file mode 100644 index 0000000..e9f4db6 --- /dev/null +++ b/src/backend/app/models/__init__.py @@ -0,0 +1,30 @@ +"""Database models package""" +from .database import ( + Base, + User, + UserRole, + ProxmoxHost, + ProxmoxNode, + ISOImage, + CloudImage, + VirtualMachine, + VMStatus, + OSType, + UpdateLog, + AuditLog, +) + +__all__ = [ + "Base", + "User", + "UserRole", + "ProxmoxHost", + "ProxmoxNode", + "ISOImage", + "CloudImage", + "VirtualMachine", + "VMStatus", + "OSType", + "UpdateLog", + "AuditLog", +] diff --git a/src/backend/app/models/database.py b/src/backend/app/models/database.py new file mode 100644 index 0000000..599da56 --- /dev/null +++ b/src/backend/app/models/database.py @@ -0,0 +1,300 @@ +""" +Database models for Depl0y +""" +from datetime import datetime +from sqlalchemy import Boolean, Column, Integer, String, DateTime, ForeignKey, Text, Enum, JSON +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import relationship +import enum + +Base = declarative_base() + + +class UserRole(str, enum.Enum): + """User role enumeration""" + ADMIN = "admin" + OPERATOR = "operator" + VIEWER = "viewer" + + +class VMStatus(str, enum.Enum): + """VM status enumeration""" + CREATING = "creating" + RUNNING = "running" + STOPPED = "stopped" + ERROR = "error" + DELETING = "deleting" + + +class OSType(str, enum.Enum): + """Operating system type""" + # Linux Server + UBUNTU = "ubuntu" + DEBIAN = "debian" + CENTOS = "centos" + ROCKY = "rocky" + ALMA = "alma" + + # Windows + WINDOWS_SERVER_2016 = "windows_server_2016" + WINDOWS_SERVER_2019 = "windows_server_2019" + WINDOWS_SERVER_2022 = "windows_server_2022" + WINDOWS_10 = "windows_10" + WINDOWS_11 = "windows_11" + + # Firewalls + PFSENSE = "pfsense" + OPNSENSE = "opnsense" + SOPHOS = "sophos" + FORTINET = "fortinet" + VYOS = "vyos" + + # Other + FREEBSD = "freebsd" + TRUENAS = "truenas" + PROXMOX = "proxmox" + ESXI = "esxi" + OTHER = "other" + + +class User(Base): + """User model""" + __tablename__ = "users" + + id = Column(Integer, primary_key=True, index=True) + username = Column(String(100), unique=True, index=True, nullable=False) + email = Column(String(255), unique=True, index=True, nullable=False) + hashed_password = Column(String(255), nullable=False) + role = Column(Enum(UserRole), default=UserRole.VIEWER, nullable=False) + is_active = Column(Boolean, default=True, nullable=False) + totp_secret = Column(String(32), nullable=True) # For 2FA + totp_enabled = Column(Boolean, default=False, nullable=False) + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + last_login = Column(DateTime, nullable=True) + + # Relationships + vms = relationship("VirtualMachine", back_populates="created_by_user") + audit_logs = relationship("AuditLog", back_populates="user") + + +class ProxmoxHost(Base): + """Proxmox VE host configuration""" + __tablename__ = "proxmox_hosts" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String(100), unique=True, nullable=False) + hostname = Column(String(255), nullable=False) + port = Column(Integer, default=8006, nullable=False) + username = Column(String(100), nullable=False) + password = Column(String(255), nullable=True) # Encrypted (optional if using token) + api_token_id = Column(String(100), nullable=True) # e.g., "root@pam!mytoken" + api_token_secret = Column(String(255), nullable=True) # Encrypted API token secret + verify_ssl = Column(Boolean, default=False, nullable=False) + is_active = Column(Boolean, default=True, nullable=False) + last_poll = Column(DateTime, nullable=True) + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + # Relationships + nodes = relationship("ProxmoxNode", back_populates="host", cascade="all, delete-orphan") + vms = relationship("VirtualMachine", back_populates="proxmox_host") + + +class ProxmoxNode(Base): + """Proxmox node (hypervisor) information""" + __tablename__ = "proxmox_nodes" + + id = Column(Integer, primary_key=True, index=True) + host_id = Column(Integer, ForeignKey("proxmox_hosts.id"), nullable=False) + node_name = Column(String(100), nullable=False) + status = Column(String(50), nullable=True) + cpu_cores = Column(Integer, nullable=True) + cpu_usage = Column(Integer, nullable=True) # Percentage + memory_total = Column(Integer, nullable=True) # Bytes + memory_used = Column(Integer, nullable=True) # Bytes + disk_total = Column(Integer, nullable=True) # Bytes + disk_used = Column(Integer, nullable=True) # Bytes + uptime = Column(Integer, nullable=True) # Seconds + last_updated = Column(DateTime, default=datetime.utcnow, nullable=False) + + # Relationships + host = relationship("ProxmoxHost", back_populates="nodes") + vms = relationship("VirtualMachine", back_populates="node") + + +class ISOImage(Base): + """ISO image storage""" + __tablename__ = "iso_images" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String(255), nullable=False) + filename = Column(String(255), unique=True, nullable=False) + os_type = Column(Enum(OSType), nullable=False) + version = Column(String(50), nullable=True) + architecture = Column(String(20), default="amd64", nullable=False) + file_size = Column(Integer, nullable=True) # Bytes + checksum = Column(String(64), nullable=True) # SHA256 + storage_path = Column(String(500), nullable=False) + uploaded_by = Column(Integer, ForeignKey("users.id"), nullable=True) + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + is_available = Column(Boolean, default=True, nullable=False) + + # Relationships + vms = relationship("VirtualMachine", back_populates="iso_image") + + +class CloudImage(Base): + """Cloud image storage for pre-installed VM images""" + __tablename__ = "cloud_images" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String(255), nullable=False) + filename = Column(String(255), unique=True, nullable=False) + os_type = Column(String(50), nullable=False) # Store as string to avoid enum issues + version = Column(String(50), nullable=True) + architecture = Column(String(20), default="amd64", nullable=False) + file_size = Column(Integer, nullable=True) # Bytes + checksum = Column(String(64), nullable=True) # SHA256 + download_url = Column(String(500), nullable=False) # Where to download from + storage_path = Column(String(500), nullable=True) # Local path once downloaded + is_downloaded = Column(Boolean, default=False, nullable=False) + download_progress = Column(Integer, default=0, nullable=False) # Percentage + download_status = Column(String(50), default="pending", nullable=False) # pending, downloading, completed, error + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + is_available = Column(Boolean, default=True, nullable=False) + + # Relationships + vms = relationship("VirtualMachine", back_populates="cloud_image") + + +class VirtualMachine(Base): + """Virtual machine deployment records""" + __tablename__ = "virtual_machines" + + id = Column(Integer, primary_key=True, index=True) + vmid = Column(Integer, nullable=True) # Proxmox VMID + name = Column(String(255), nullable=False) + hostname = Column(String(255), nullable=False) + + # Proxmox references + proxmox_host_id = Column(Integer, ForeignKey("proxmox_hosts.id"), nullable=False) + node_id = Column(Integer, ForeignKey("proxmox_nodes.id"), nullable=True) + iso_id = Column(Integer, ForeignKey("iso_images.id"), nullable=True) + cloud_image_id = Column(Integer, ForeignKey("cloud_images.id"), nullable=True) + + # VM specifications + os_type = Column(Enum(OSType), nullable=False) + cpu_sockets = Column(Integer, default=1, nullable=False) + cpu_cores = Column(Integer, nullable=False) + cpu_type = Column(String(100), default="host", nullable=True) # CPU type: host, qemu64, kvm64, etc. + cpu_flags = Column(String(500), nullable=True) # Additional CPU flags + cpu_limit = Column(Integer, nullable=True) # CPU usage limit (0 = unlimited) + cpu_units = Column(Integer, default=1024, nullable=True) # CPU weight for scheduler + numa_enabled = Column(Boolean, default=False, nullable=False) # NUMA support + memory = Column(Integer, nullable=False) # MB + balloon = Column(Integer, nullable=True) # Balloon device (0 = disabled, MB) + shares = Column(Integer, nullable=True) # Memory shares for scheduler + disk_size = Column(Integer, nullable=False) # GB + storage = Column(String(100), nullable=True) # Storage pool name for VM disks + iso_storage = Column(String(100), nullable=True) # Storage pool name for ISO files + scsihw = Column(String(50), default="virtio-scsi-pci", nullable=True) # SCSI controller type + + # Hardware options + bios_type = Column(String(20), default="seabios", nullable=False) # seabios or ovmf (UEFI) + machine_type = Column(String(50), default="pc", nullable=True) # pc, q35, etc. + vga_type = Column(String(50), default="std", nullable=False) # std, virtio, qxl, vmware, cirrus + boot_order = Column(String(100), default="cdn", nullable=False) # c=disk, d=cdrom, n=network + onboot = Column(Boolean, default=True, nullable=False) # Start VM at boot + tablet = Column(Boolean, default=True, nullable=False) # Enable tablet pointer device + hotplug = Column(String(200), nullable=True) # Hotplug options: disk,network,usb,memory,cpu + protection = Column(Boolean, default=False, nullable=False) # Prevent accidental deletion + startup_order = Column(Integer, nullable=True) # Startup order + startup_up = Column(Integer, nullable=True) # Startup delay in seconds + startup_down = Column(Integer, nullable=True) # Shutdown timeout in seconds + kvm = Column(Boolean, default=True, nullable=False) # Enable KVM hardware virtualization + acpi = Column(Boolean, default=True, nullable=False) # Enable ACPI + agent_enabled = Column(Boolean, default=True, nullable=False) # QEMU guest agent + description = Column(Text, nullable=True) # VM description + tags = Column(String(500), nullable=True) # VM tags (semicolon-separated) + + # Network configuration + network_bridge = Column(String(100), nullable=True) # Primary network bridge + network_interfaces = Column(JSON, nullable=True) # Additional network interfaces as JSON array + ip_address = Column(String(45), nullable=True) # IPv4/IPv6 + gateway = Column(String(45), nullable=True) + netmask = Column(String(45), nullable=True) + dns_servers = Column(String(255), nullable=True) + + # Credentials (encrypted) + username = Column(String(100), nullable=False) + password = Column(String(255), nullable=False) + ssh_key = Column(Text, nullable=True) + + # Status and metadata + status = Column(Enum(VMStatus), default=VMStatus.CREATING, nullable=False) + status_message = Column(String(500), nullable=True) # Real-time progress message + cloud_init_config = Column(JSON, nullable=True) + error_message = Column(Text, nullable=True) + + # Timestamps + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + deployed_at = Column(DateTime, nullable=True) + last_updated = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + # User reference + created_by = Column(Integer, ForeignKey("users.id"), nullable=False) + + # Relationships + created_by_user = relationship("User", back_populates="vms") + proxmox_host = relationship("ProxmoxHost", back_populates="vms") + node = relationship("ProxmoxNode", back_populates="vms") + iso_image = relationship("ISOImage", back_populates="vms") + cloud_image = relationship("CloudImage", back_populates="vms") + update_logs = relationship("UpdateLog", back_populates="vm", cascade="all, delete-orphan") + + +class UpdateLog(Base): + """Update history for VMs""" + __tablename__ = "update_logs" + + id = Column(Integer, primary_key=True, index=True) + vm_id = Column(Integer, ForeignKey("virtual_machines.id"), nullable=False) + initiated_by = Column(Integer, ForeignKey("users.id"), nullable=False) + status = Column(String(50), nullable=False) # pending, running, completed, failed + packages_updated = Column(Integer, default=0, nullable=False) + output = Column(Text, nullable=True) + error_message = Column(Text, nullable=True) + started_at = Column(DateTime, default=datetime.utcnow, nullable=False) + completed_at = Column(DateTime, nullable=True) + + # Relationships + vm = relationship("VirtualMachine", back_populates="update_logs") + + +class AuditLog(Base): + """Audit log for tracking user actions""" + __tablename__ = "audit_logs" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=True) + action = Column(String(100), nullable=False) + resource_type = Column(String(50), nullable=True) + resource_id = Column(Integer, nullable=True) + details = Column(JSON, nullable=True) + ip_address = Column(String(45), nullable=True) + user_agent = Column(String(255), nullable=True) + timestamp = Column(DateTime, default=datetime.utcnow, nullable=False) + + # Relationships + user = relationship("User", back_populates="audit_logs") + +class SystemSettings(Base): + """System-wide settings and configuration""" + __tablename__ = "system_settings" + + id = Column(Integer, primary_key=True, index=True) + key = Column(String(100), unique=True, nullable=False, index=True) + value = Column(Text, nullable=False) + description = Column(Text, nullable=True) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) diff --git a/src/backend/app/services/__init__.py b/src/backend/app/services/__init__.py new file mode 100644 index 0000000..e372604 --- /dev/null +++ b/src/backend/app/services/__init__.py @@ -0,0 +1 @@ +"""Services package""" diff --git a/src/backend/app/services/cloud_images.py b/src/backend/app/services/cloud_images.py new file mode 100644 index 0000000..487853e --- /dev/null +++ b/src/backend/app/services/cloud_images.py @@ -0,0 +1,218 @@ +"""Cloud Image Download Service""" +import os +import requests +import logging +from sqlalchemy.orm import Session +from app.models import CloudImage +from app.core.config import settings + +logger = logging.getLogger(__name__) + +# Directory to store downloaded cloud images +CLOUD_IMAGES_DIR = os.path.join(settings.UPLOAD_DIR, "cloud-images") +os.makedirs(CLOUD_IMAGES_DIR, exist_ok=True) + + +def download_cloud_image_task(image_id: int, db: Session): + """Download a cloud image from URL with progress tracking""" + try: + image = db.query(CloudImage).filter(CloudImage.id == image_id).first() + if not image: + logger.error(f"Cloud image {image_id} not found") + return + + if image.is_downloaded: + logger.info(f"Cloud image {image.id} already downloaded") + return + + # Update status + image.download_status = "downloading" + image.download_progress = 0 + db.commit() + + logger.info(f"Starting download of {image.name} from {image.download_url}") + + # Download the file + response = requests.get(image.download_url, stream=True, timeout=30) + response.raise_for_status() + + # Get file size from headers + total_size = int(response.headers.get('content-length', 0)) + image.file_size = total_size + db.commit() + + # Determine file path + storage_path = os.path.join(CLOUD_IMAGES_DIR, image.filename) + image.storage_path = storage_path + + # Download with progress tracking + downloaded = 0 + chunk_size = 8192 # 8KB chunks + last_progress = 0 + + logger.info(f"Downloading {total_size / (1024 * 1024):.1f} MB to {storage_path}") + + with open(storage_path, 'wb') as f: + for chunk in response.iter_content(chunk_size=chunk_size): + if chunk: + f.write(chunk) + downloaded += len(chunk) + + # Update progress every 5% + if total_size > 0: + progress = int((downloaded / total_size) * 100) + if progress >= last_progress + 5 or progress == 100: + image.download_progress = progress + db.commit() + logger.info(f"Download progress: {progress}% ({downloaded / (1024 * 1024):.1f} MB)") + last_progress = progress + + # Mark as completed + image.download_status = "completed" + image.download_progress = 100 + image.is_downloaded = True + db.commit() + + logger.info(f"Successfully downloaded cloud image: {image.name}") + + except requests.exceptions.RequestException as e: + logger.error(f"Failed to download cloud image: {e}") + if image: + image.download_status = "error" + image.download_progress = 0 + db.commit() + except Exception as e: + logger.error(f"Unexpected error during download: {e}") + if image: + image.download_status = "error" + image.download_progress = 0 + db.commit() + + +def get_cloud_image_path(image_id: int, db: Session) -> str | None: + """Get the local path of a downloaded cloud image""" + try: + image = db.query(CloudImage).filter(CloudImage.id == image_id).first() + if not image: + return None + + if not image.is_downloaded or not image.storage_path: + return None + + if not os.path.exists(image.storage_path): + logger.warning(f"Cloud image file not found at {image.storage_path}") + image.is_downloaded = False + db.commit() + return None + + return image.storage_path + except Exception as e: + logger.error(f"Error getting cloud image path: {e}") + return None + + +def create_template_on_node(image_id: int, node_id: int, template_vmid: int, db: Session): + """Create a cloud image template on a Proxmox node""" + try: + from app.models import CloudImage, ProxmoxNode, ProxmoxHost + from app.services.proxmox import ProxmoxService + import time + + image = db.query(CloudImage).filter(CloudImage.id == image_id).first() + if not image: + logger.error(f"Cloud image {image_id} not found") + return + + node = db.query(ProxmoxNode).filter(ProxmoxNode.id == node_id).first() + if not node: + logger.error(f"Node {node_id} not found") + return + + host = db.query(ProxmoxHost).filter(ProxmoxHost.id == node.host_id).first() + if not host: + logger.error(f"Proxmox host not found for node {node_id}") + return + + proxmox = ProxmoxService(host) + + logger.info(f"Creating template {template_vmid} for {image.name} on node {node.node_name}") + + # Check if template already exists + try: + proxmox.proxmox.nodes(node.node_name).qemu(template_vmid).status.current.get() + logger.info(f"Template {template_vmid} already exists on {node.node_name}") + return + except: + pass # Template doesn't exist, create it + + # Step 1: Download cloud image if not already downloaded + if not image.is_downloaded or not image.storage_path: + logger.info(f"Downloading cloud image {image.name}") + download_cloud_image_task(image_id, db) + + # Wait for download to complete + max_wait = 3600 # 1 hour max + waited = 0 + while not image.is_downloaded and waited < max_wait: + time.sleep(5) + waited += 5 + db.refresh(image) + + if not image.is_downloaded: + raise Exception(f"Cloud image download timed out after {max_wait} seconds") + + # Step 2: Upload cloud image to Proxmox + logger.info(f"Uploading cloud image to {node.node_name}") + + # Use Proxmox API to upload to ISO storage + image_filename = image.filename + with open(image.storage_path, 'rb') as f: + # Upload via Proxmox API + upload_url = f"/nodes/{node.node_name}/storage/local/upload" + files = {'filename': (image_filename, f)} + data = {'content': 'iso'} + + proxmox.proxmox.nodes(node.node_name).storage('local').upload.post( + content='iso', + filename=f + ) + + logger.info(f"Cloud image uploaded to {node.node_name}:local/iso/{image_filename}") + + # Step 3: Create VM shell + logger.info(f"Creating VM shell {template_vmid}") + proxmox.proxmox.nodes(node.node_name).qemu.create( + vmid=template_vmid, + name=f"cloud-{image.os_type}-{image.version or 'latest'}", + memory=2048, + cores=2, + sockets=1, + net0='virtio,bridge=vmbr0', + scsihw='virtio-scsi-pci', + ostype='l26' if image.os_type != 'windows' else 'win10', + agent=1 + ) + + time.sleep(2) + + # Step 4: Import disk using qm importdisk (requires SSH/exec) + # For now, we'll add a note that manual import is needed + logger.warning(f"Template {template_vmid} created but requires manual disk import") + logger.warning(f"Run on Proxmox node: qm importdisk {template_vmid} /var/lib/vz/template/iso/{image_filename} local-lvm") + + # Step 5: Configure disk and cloud-init + proxmox.proxmox.nodes(node.node_name).qemu(template_vmid).config.put( + scsi0='local-lvm:0,import-from=/var/lib/vz/template/iso/' + image_filename, + ide2='local-lvm:cloudinit', + boot='order=scsi0', + serial0='socket', + vga='serial0' + ) + + # Step 6: Convert to template + proxmox.proxmox.nodes(node.node_name).qemu(template_vmid).template.post() + + logger.info(f"Successfully created template {template_vmid} on {node.node_name}") + + except Exception as e: + logger.error(f"Failed to create template: {e}", exc_info=True) diff --git a/src/backend/app/services/cloudinit.py b/src/backend/app/services/cloudinit.py new file mode 100644 index 0000000..21dd42e --- /dev/null +++ b/src/backend/app/services/cloudinit.py @@ -0,0 +1,293 @@ +"""Cloud-init configuration service""" +import yaml +from typing import Optional, List, Dict, Any +import logging + +logger = logging.getLogger(__name__) + + +class CloudInitService: + """Service for generating cloud-init configurations""" + + @staticmethod + def generate_user_data( + hostname: str, + username: str, + password: Optional[str] = None, + ssh_keys: Optional[List[str]] = None, + packages: Optional[List[str]] = None, + install_qemu_agent: bool = True, + timezone: str = "UTC", + additional_commands: Optional[List[str]] = None, + ) -> str: + """ + Generate cloud-init user-data configuration + + Args: + hostname: VM hostname + username: Default user username + password: User password (will be hashed by cloud-init) + ssh_keys: List of SSH public keys + packages: Additional packages to install + install_qemu_agent: Install QEMU guest agent + timezone: System timezone + additional_commands: Additional commands to run + + Returns: + YAML formatted user-data string + """ + config = { + "hostname": hostname, + "fqdn": f"{hostname}.local", + "manage_etc_hosts": True, + "timezone": timezone, + "users": [], + "ssh_pwauth": True if password else False, + "disable_root": True, + "package_update": True, + "package_upgrade": True, + "packages": packages or [], + "runcmd": additional_commands or [], + } + + # Configure default user + user_config = { + "name": username, + "groups": ["sudo", "users", "admin"], + "shell": "/bin/bash", + "sudo": ["ALL=(ALL) NOPASSWD:ALL"], + } + + if password: + user_config["password"] = password + user_config["lock_passwd"] = False + else: + user_config["lock_passwd"] = True + + if ssh_keys: + user_config["ssh_authorized_keys"] = ssh_keys + + config["users"].append(user_config) + + # Add QEMU guest agent installation + if install_qemu_agent: + if "qemu-guest-agent" not in config["packages"]: + config["packages"].append("qemu-guest-agent") + + # Ensure agent starts on boot + config["runcmd"].extend([ + "systemctl enable qemu-guest-agent", + "systemctl start qemu-guest-agent", + ]) + + # Add SSH server if not present + if "openssh-server" not in config["packages"]: + config["packages"].append("openssh-server") + + # Ensure SSH is enabled + config["runcmd"].extend([ + "systemctl enable ssh", + "systemctl start ssh", + ]) + + # Generate YAML with cloud-config header + user_data = "#cloud-config\n" + yaml.dump( + config, default_flow_style=False, sort_keys=False + ) + + return user_data + + @staticmethod + def generate_network_config( + use_dhcp: bool = True, + ip_address: Optional[str] = None, + netmask: Optional[str] = None, + gateway: Optional[str] = None, + nameservers: Optional[List[str]] = None, + interface: str = "eth0", + ) -> str: + """ + Generate cloud-init network configuration + + Args: + use_dhcp: Use DHCP for network configuration + ip_address: Static IP address + netmask: Network mask + gateway: Default gateway + nameservers: DNS nameservers + interface: Network interface name + + Returns: + YAML formatted network config string + """ + if use_dhcp: + config = { + "version": 2, + "ethernets": { + interface: { + "dhcp4": True, + "dhcp6": False, + } + }, + } + else: + if not all([ip_address, netmask, gateway]): + raise ValueError( + "Static IP configuration requires ip_address, netmask, and gateway" + ) + + config = { + "version": 2, + "ethernets": { + interface: { + "dhcp4": False, + "dhcp6": False, + "addresses": [f"{ip_address}/{netmask}"], + "gateway4": gateway, + } + }, + } + + if nameservers: + config["ethernets"][interface]["nameservers"] = { + "addresses": nameservers + } + + return yaml.dump(config, default_flow_style=False, sort_keys=False) + + @staticmethod + def generate_meta_data( + instance_id: str, + hostname: str, + ) -> str: + """ + Generate cloud-init meta-data + + Args: + instance_id: Unique instance identifier + hostname: VM hostname + + Returns: + YAML formatted meta-data string + """ + config = { + "instance-id": instance_id, + "local-hostname": hostname, + } + + return yaml.dump(config, default_flow_style=False, sort_keys=False) + + @staticmethod + def generate_partition_config( + disk_device: str = "/dev/sda", + scheme: str = "single", + ) -> Dict[str, Any]: + """ + Generate disk partition configuration for cloud-init + + Args: + disk_device: Disk device path + scheme: Partitioning scheme (single or custom) + + Returns: + Partition configuration dictionary + """ + if scheme == "single": + # Single large partition with necessary boot partitions + return { + "layout": { + "name": "direct", + "match": { + "serial": "*", + }, + }, + "partitions": [ + { + "id": "boot-partition", + "type": "partition", + "device": disk_device, + "size": "512M", + "flag": "boot", + "grub_device": True, + }, + { + "id": "root-partition", + "type": "partition", + "device": disk_device, + "size": "-1", # Use remaining space + }, + ], + "filesystems": [ + { + "id": "boot-fs", + "type": "ext4", + "partition": "boot-partition", + }, + { + "id": "root-fs", + "type": "ext4", + "partition": "root-partition", + }, + ], + "mounts": [ + ["boot-fs", "/boot"], + ["root-fs", "/"], + ], + } + else: + # Custom scheme - can be extended based on requirements + return { + "layout": "lvm", + "partitions": [], + } + + @staticmethod + def create_complete_config( + hostname: str, + username: str, + password: Optional[str] = None, + ssh_keys: Optional[List[str]] = None, + use_dhcp: bool = True, + ip_address: Optional[str] = None, + netmask: Optional[str] = None, + gateway: Optional[str] = None, + nameservers: Optional[List[str]] = None, + packages: Optional[List[str]] = None, + install_qemu_agent: bool = True, + partition_scheme: str = "single", + ) -> Dict[str, str]: + """ + Create complete cloud-init configuration with user-data, meta-data, and network-config + + Returns: + Dictionary with 'user_data', 'meta_data', and 'network_config' keys + """ + instance_id = f"depl0y-{hostname}" + + user_data = CloudInitService.generate_user_data( + hostname=hostname, + username=username, + password=password, + ssh_keys=ssh_keys, + packages=packages, + install_qemu_agent=install_qemu_agent, + ) + + meta_data = CloudInitService.generate_meta_data( + instance_id=instance_id, + hostname=hostname, + ) + + network_config = CloudInitService.generate_network_config( + use_dhcp=use_dhcp, + ip_address=ip_address, + netmask=netmask, + gateway=gateway, + nameservers=nameservers, + ) + + return { + "user_data": user_data, + "meta_data": meta_data, + "network_config": network_config, + } diff --git a/src/backend/app/services/deployment.py b/src/backend/app/services/deployment.py new file mode 100644 index 0000000..919a9d2 --- /dev/null +++ b/src/backend/app/services/deployment.py @@ -0,0 +1,1666 @@ +"""VM deployment service""" +from typing import Dict, Any, Optional +from sqlalchemy.orm import Session +from app.models import ( + VirtualMachine, + VMStatus, + ProxmoxHost, + ProxmoxNode, + ISOImage, + OSType, +) +from app.services.proxmox import ProxmoxService +from app.services.cloudinit import CloudInitService +from app.core.security import encrypt_data +import logging +import time +from datetime import datetime + +logger = logging.getLogger(__name__) + + +class DeploymentService: + """Service for deploying VMs""" + + def __init__(self, db: Session): + self.db = db + + def _clear_vm_locks(self, proxmox, node, vmid, host): + """ + Proactively clear any locks on a VM before operations using SSH as root + + Args: + proxmox: ProxmoxService instance + node: ProxmoxNode instance + vmid: VM ID to clear locks from + host: ProxmoxHost instance + """ + import subprocess + + try: + # Check if VM has a lock attribute + vm_config = proxmox.proxmox.nodes(node.node_name).qemu(vmid).config.get() + if 'lock' in vm_config: + lock_type = vm_config['lock'] + logger.warning(f"VM {vmid} has '{lock_type}' lock, removing it via SSH as root...") + + try: + # Get node IP from corosync.conf + get_ip_cmd = f"ssh -o StrictHostKeyChecking=no root@{host.hostname} \"grep -A3 'name: {node.node_name}' /etc/pve/corosync.conf | grep ring0_addr | awk '{{print \\$2}}'\"" + ip_result = subprocess.run(get_ip_cmd, shell=True, capture_output=True, text=True) + + if ip_result.returncode == 0 and ip_result.stdout.strip(): + node_ip = ip_result.stdout.strip() + + # Remove lock directly from config file via SSH + # Use qm unlock command which requires root privileges + unlock_cmd = f"ssh -o StrictHostKeyChecking=no root@{host.hostname} 'ssh -o StrictHostKeyChecking=no root@{node_ip} \"qm unlock {vmid}\"'" + unlock_result = subprocess.run(unlock_cmd, shell=True, capture_output=True, text=True) + + if unlock_result.returncode == 0: + logger.info(f"Successfully removed '{lock_type}' lock from VM {vmid} using qm unlock") + + # Wait for lock to be fully released + time.sleep(2) + + # Verify lock was removed + vm_config_after = proxmox.proxmox.nodes(node.node_name).qemu(vmid).config.get() + if 'lock' not in vm_config_after: + logger.info(f"Verified lock removed from VM {vmid}") + else: + logger.warning(f"Lock still present on VM {vmid} after qm unlock") + else: + logger.error(f"Failed to unlock VM {vmid} via SSH: {unlock_result.stderr}") + else: + logger.error(f"Failed to get node IP for lock removal") + + except Exception as lock_err: + logger.error(f"Failed to remove lock from VM {vmid}: {lock_err}") + except Exception as e: + # If VM doesn't exist or other error, just log and continue + logger.debug(f"Could not check/clear locks on VM {vmid}: {e}") + + def _retry_with_lock_cleanup(self, operation_func, host, node, proxmox=None, vmid=None, max_retries=2): + """ + Retry an operation with automatic stale lock cleanup + + Args: + operation_func: Function to execute (should raise exception on lock error) + host: ProxmoxHost instance + node: ProxmoxNode instance + proxmox: ProxmoxService instance (optional, needed for VM lock removal) + vmid: VM ID (optional, needed for VM lock removal) + max_retries: Maximum number of retry attempts + + Returns: + Result of operation_func + """ + import subprocess + import re + + # Proactively clear any VM locks before starting + if proxmox and vmid: + self._clear_vm_locks(proxmox, node, vmid, host) + + for attempt in range(max_retries): + try: + return operation_func() + except Exception as e: + error_msg = str(e) + + # Check for VM lock error (e.g., "VM is locked (clone)") + if "VM is locked" in error_msg and attempt < max_retries - 1: + if proxmox and vmid: + logger.warning(f"VM {vmid} is locked, removing lock via SSH...") + # Use SSH-based lock removal since API requires root + self._clear_vm_locks(proxmox, node, vmid, host) + time.sleep(2) + continue + + # Check for file lock error (e.g., "can't lock file '/var/lock/qemu-server/lock-XXX.conf'") + if "can't lock file" in error_msg and attempt < max_retries - 1: + # Extract VMID from lock error + lock_match = re.search(r'lock-(\d+)\.conf', error_msg) + if lock_match: + locked_vmid = lock_match.group(1) + logger.warning(f"Stale lock file detected for VM {locked_vmid} during operation, removing it...") + + # Get node IP from corosync.conf + get_ip_cmd = f"ssh -o StrictHostKeyChecking=no root@{host.hostname} \"grep -A3 'name: {node.node_name}' /etc/pve/corosync.conf | grep ring0_addr | awk '{{print \\$2}}'\"" + ip_result = subprocess.run(get_ip_cmd, shell=True, capture_output=True, text=True) + + if ip_result.returncode == 0 and ip_result.stdout.strip(): + node_ip = ip_result.stdout.strip() + # Remove stale lock file + cleanup_cmd = f"ssh -o StrictHostKeyChecking=no root@{host.hostname} 'ssh -o StrictHostKeyChecking=no root@{node_ip} \"rm -f /var/lock/qemu-server/lock-{locked_vmid}.conf\"'" + cleanup_result = subprocess.run(cleanup_cmd, shell=True, capture_output=True, text=True) + + if cleanup_result.returncode == 0: + logger.info(f"Successfully removed stale lock file for VM {locked_vmid}, retrying operation...") + + # Also clear VM lock attribute after removing lock file + if proxmox and vmid: + self._clear_vm_locks(proxmox, node, vmid, host) + + time.sleep(2) + continue + else: + logger.error(f"Failed to remove lock file: {cleanup_result.stderr}") + else: + logger.error(f"Failed to get node IP for lock cleanup") + + # If not a lock error, or retry failed, re-raise + raise + + def _ensure_virtio_iso( + self, + proxmox: ProxmoxService, + node_name: str, + storage: str = "local" + ) -> Optional[str]: + """ + Ensure VirtIO drivers ISO exists in storage, download if needed + + Args: + proxmox: ProxmoxService instance + node_name: Node name where to check/download + storage: Storage name (default: local) + + Returns: + ISO path if available, None otherwise + """ + try: + virtio_filename = "virtio-win.iso" + + # Check if VirtIO ISO already exists in storage + logger.info(f"Checking for VirtIO ISO in {storage}:iso/") + try: + storage_content = proxmox.proxmox.nodes(node_name).storage(storage).content.get() + for item in storage_content: + if item.get('volid', '').endswith(virtio_filename): + logger.info(f"Found existing VirtIO ISO: {item['volid']}") + return f"{storage}:iso/{virtio_filename}" + except Exception as e: + logger.warning(f"Could not check storage content: {e}") + + # VirtIO ISO not found, download it + logger.info(f"VirtIO ISO not found in {storage}, downloading...") + + # Use latest stable VirtIO drivers ISO URL + virtio_url = "https://fedorapeople.org/groups/virt/virtio-win/direct-downloads/stable-virtio/virtio-win.iso" + + # Download ISO to storage using Proxmox API + logger.info(f"Downloading VirtIO ISO from {virtio_url}") + download_task = proxmox.proxmox.nodes(node_name).storage(storage).download_url.post( + content='iso', + filename=virtio_filename, + url=virtio_url + ) + + logger.info(f"VirtIO ISO download initiated: {download_task}") + + # Wait for download to complete (with timeout) + max_wait = 300 # 5 minutes timeout + waited = 0 + while waited < max_wait: + try: + # Check if ISO now exists + storage_content = proxmox.proxmox.nodes(node_name).storage(storage).content.get() + for item in storage_content: + if item.get('volid', '').endswith(virtio_filename): + logger.info(f"VirtIO ISO download completed: {item['volid']}") + return f"{storage}:iso/{virtio_filename}" + except Exception: + pass + + time.sleep(5) + waited += 5 + + logger.warning("VirtIO ISO download timed out") + return None + + except Exception as e: + logger.error(f"Failed to ensure VirtIO ISO: {e}") + return None + + def deploy_linux_vm( + self, + vm_id: int, + ) -> bool: + """ + Deploy a Linux VM with cloud-init + + Args: + vm_id: Database ID of the VM to deploy + + Returns: + True if deployment successful, False otherwise + """ + try: + # Get VM record + vm = self.db.query(VirtualMachine).filter(VirtualMachine.id == vm_id).first() + if not vm: + logger.error(f"VM {vm_id} not found") + return False + + logger.info(f"Starting Linux VM deployment for VM ID {vm_id}, Name: {vm.name}, OS: {vm.os_type.value}") + + # Update status + vm.status = VMStatus.CREATING + vm.status_message = "Initializing VM deployment..." + self.db.commit() + + # Get Proxmox host + vm.status_message = "Connecting to Proxmox datacenter..." + self.db.commit() + host = ( + self.db.query(ProxmoxHost) + .filter(ProxmoxHost.id == vm.proxmox_host_id) + .first() + ) + if not host: + raise Exception("Proxmox host not found") + + # Get node + vm.status_message = "Locating target node..." + self.db.commit() + node = self.db.query(ProxmoxNode).filter(ProxmoxNode.id == vm.node_id).first() + if not node: + raise Exception("Proxmox node not found") + + # Initialize Proxmox service + vm.status_message = "Establishing connection to Proxmox API..." + self.db.commit() + proxmox = ProxmoxService(host) + + # Get next VMID + vm.status_message = "Allocating VM ID..." + self.db.commit() + vmid = proxmox.get_next_vmid() + vm.vmid = vmid + logger.info(f"Allocated VMID {vmid} for VM {vm.name}") + + # Check if using cloud image instead of ISO + if vm.cloud_image_id: + from app.models import CloudImage + cloud_image = self.db.query(CloudImage).filter(CloudImage.id == vm.cloud_image_id).first() + if not cloud_image: + raise Exception("Cloud image not found") + + logger.info(f"Using cloud image {cloud_image.name} for VM {vm.name}") + return self._deploy_from_cloud_image(vm, host, node, proxmox, vmid, cloud_image) + + # Get ISO if specified + vm.status_message = "Preparing installation media..." + self.db.commit() + iso_path = None + if vm.iso_id: + iso = self.db.query(ISOImage).filter(ISOImage.id == vm.iso_id).first() + if iso: + # Use user-selected ISO storage or default to 'local' + iso_storage = vm.iso_storage if vm.iso_storage else "local" + logger.info(f"Using ISO storage: {iso_storage}") + + # Sanitize filename for Proxmox (replace spaces and special chars) + # Proxmox has issues with spaces and parentheses in filenames + import re + sanitized_filename = re.sub(r'[^\w\.-]', '_', iso.filename) + logger.info(f"Original filename: {iso.filename}, Sanitized: {sanitized_filename}") + + # Check if ISO exists on Proxmox storage (with sanitized name) + logger.info(f"Checking if ISO {sanitized_filename} exists on {node.node_name}:{iso_storage}") + iso_exists = proxmox.iso_exists_on_storage(node.node_name, iso_storage, sanitized_filename) + + if not iso_exists: + logger.info(f"ISO {sanitized_filename} not found on Proxmox, uploading...") + + # Define progress callback to update VM status + def update_progress(percent, message): + vm.status_message = message + self.db.commit() + logger.info(f"Upload progress: {message}") + + vm.status_message = f"Uploading ISO {iso.name} to Proxmox (this may take several minutes)..." + self.db.commit() + + # Upload ISO to Proxmox with sanitized filename + upload_success = proxmox.upload_iso( + node_name=node.node_name, + storage=iso_storage, + iso_path=iso.storage_path, + filename=sanitized_filename, + progress_callback=update_progress + ) + + if not upload_success: + raise Exception(f"Failed to upload ISO {sanitized_filename} to Proxmox") + + logger.info(f"Successfully uploaded ISO {sanitized_filename} to Proxmox") + vm.status_message = "ISO upload complete! Preparing VM configuration..." + self.db.commit() + else: + logger.info(f"ISO {sanitized_filename} already exists on Proxmox") + vm.status_message = "ISO found on Proxmox. Preparing VM configuration..." + self.db.commit() + + iso_path = f"{iso_storage}:iso/{sanitized_filename}" + + # Create VM + vm.status_message = f"Creating VM {vmid} on node {node.node_name}..." + self.db.commit() + logger.info(f"Creating VM {vmid} on node {node.node_name}") + + # Use selected storage or default to local-lvm + storage = vm.storage if vm.storage else "local-lvm" + network_bridge = vm.network_bridge if vm.network_bridge else "vmbr0" + logger.info(f"Using storage: {storage}, network bridge: {network_bridge} for VM {vmid}") + + success = proxmox.create_vm( + node_name=node.node_name, + vmid=vmid, + name=vm.name, + sockets=vm.cpu_sockets, + cores=vm.cpu_cores, + memory=vm.memory, + disk_size=vm.disk_size, + storage=storage, + iso=iso_path, + network_bridge=network_bridge, + # Advanced options + cpu_type=vm.cpu_type or "host", + cpu_flags=vm.cpu_flags, + numa_enabled=vm.numa_enabled or False, + bios_type=vm.bios_type or "seabios", + machine_type=vm.machine_type or "pc", + vga_type=vm.vga_type or "std", + boot_order=vm.boot_order or "cdn", + network_interfaces=vm.network_interfaces, + ) + + if not success: + raise Exception("Failed to create VM in Proxmox") + + # Wait a moment for Proxmox to finalize VM creation + vm.status_message = "VM created successfully! Finalizing configuration..." + self.db.commit() + logger.info(f"Waiting for Proxmox to finalize VM {vmid} configuration...") + time.sleep(2) + + # NOTE: Cloud-init only works with cloud images, not ISO deployments + # ISO deployments always require manual setup through the installer + logger.info(f"ISO deployment for {vm.os_type.value} - manual OS installation required") + vm.status_message = f"VM created from ISO. Boot VM to begin OS installation..." + self.db.commit() + + # Start VM + vm.status_message = "Starting VM and initializing OS..." + self.db.commit() + logger.info(f"Starting VM {vmid}") + success = proxmox.start_vm(node.node_name, vmid) + + if not success: + raise Exception("Failed to start VM") + + # Wait for VM to start + vm.status_message = "Waiting for VM to boot up..." + self.db.commit() + time.sleep(5) + + # Update VM status with appropriate message + # Check again which OSes support cloud-init for status message + cloud_init_os_types = [OSType.UBUNTU, OSType.DEBIAN, OSType.CENTOS, OSType.ROCKY, OSType.ALMA] + vm.status = VMStatus.RUNNING + if vm.os_type in cloud_init_os_types: + vm.status_message = "VM deployed successfully! Cloud-init is configuring the OS..." + else: + vm.status_message = f"VM created successfully! Access via console for initial {vm.os_type.value} setup." + vm.deployed_at = datetime.utcnow() + self.db.commit() + + logger.info(f"Successfully deployed VM {vmid} ({vm.os_type.value})") + return True + + except Exception as e: + logger.error(f"Failed to deploy VM: {e}") + if vm: + vm.status = VMStatus.ERROR + vm.error_message = str(e) + self.db.commit() + return False + + def deploy_windows_vm( + self, + vm_id: int, + ) -> bool: + """ + Deploy a Windows VM + + Args: + vm_id: Database ID of the VM to deploy + + Returns: + True if deployment successful, False otherwise + """ + try: + # Get VM record + vm = self.db.query(VirtualMachine).filter(VirtualMachine.id == vm_id).first() + if not vm: + logger.error(f"VM {vm_id} not found") + return False + + logger.info(f"Starting Windows VM deployment for VM ID {vm_id}, Name: {vm.name}, OS: {vm.os_type.value}") + + # Update status + vm.status = VMStatus.CREATING + vm.status_message = "Initializing Windows VM deployment..." + self.db.commit() + + # Get Proxmox host + vm.status_message = "Connecting to Proxmox datacenter..." + self.db.commit() + host = ( + self.db.query(ProxmoxHost) + .filter(ProxmoxHost.id == vm.proxmox_host_id) + .first() + ) + if not host: + raise Exception("Proxmox host not found") + + # Get node + vm.status_message = "Locating target node..." + self.db.commit() + node = self.db.query(ProxmoxNode).filter(ProxmoxNode.id == vm.node_id).first() + if not node: + raise Exception("Proxmox node not found") + + # Initialize Proxmox service + vm.status_message = "Establishing connection to Proxmox API..." + self.db.commit() + proxmox = ProxmoxService(host) + + # Get next VMID + vm.status_message = "Allocating VM ID..." + self.db.commit() + vmid = proxmox.get_next_vmid() + vm.vmid = vmid + logger.info(f"Allocated VMID {vmid} for VM {vm.name}") + + # Get ISO + vm.status_message = "Preparing Windows installation media..." + self.db.commit() + iso_path = None + if vm.iso_id: + iso = self.db.query(ISOImage).filter(ISOImage.id == vm.iso_id).first() + if iso: + # Use user-selected ISO storage or default to 'local' + iso_storage = vm.iso_storage if vm.iso_storage else "local" + logger.info(f"Using ISO storage: {iso_storage} for Windows VM") + + # Sanitize filename for Proxmox (replace spaces and special chars) + import re + sanitized_filename = re.sub(r'[^\w\.-]', '_', iso.filename) + logger.info(f"Original filename: {iso.filename}, Sanitized: {sanitized_filename}") + iso_path = f"{iso_storage}:iso/{sanitized_filename}" + + # Create Windows VM (different settings than Linux) + vm.status_message = f"Creating Windows VM {vmid} on node {node.node_name}..." + self.db.commit() + logger.info(f"Creating Windows VM {vmid} on node {node.node_name}") + + # Use selected storage or default to local-lvm + storage = vm.storage if vm.storage else "local-lvm" + network_bridge = vm.network_bridge if vm.network_bridge else "vmbr0" + logger.info(f"Using storage: {storage}, network bridge: {network_bridge} for Windows VM {vmid}") + + # For Windows, we create the VM manually with appropriate settings + vm_config = { + "vmid": vmid, + "name": vm.name, + "cores": vm.cpu_cores, + "memory": vm.memory, + "scsihw": "virtio-scsi-pci", + "scsi0": f"{storage}:{vm.disk_size}", + "net0": f"virtio,bridge={network_bridge}", + "ostype": "win10", # Windows OS type + "agent": 1, # Enable QEMU guest agent + "cpu": "host", + "bios": "ovmf", # UEFI for modern Windows + } + + if iso_path: + vm_config["ide2"] = f"{iso_path},media=cdrom" + + # Ensure VirtIO drivers ISO is available and add it + vm.status_message = "Preparing VirtIO drivers..." + self.db.commit() + iso_storage = vm.iso_storage if vm.iso_storage else "local" + virtio_iso_path = self._ensure_virtio_iso(proxmox, node.node_name, iso_storage) + + if virtio_iso_path: + logger.info(f"Adding VirtIO ISO: {virtio_iso_path}") + vm_config["ide0"] = f"{virtio_iso_path},media=cdrom" + else: + logger.warning("VirtIO ISO not available, Windows VM will be created without VirtIO drivers ISO") + + proxmox.proxmox.nodes(node.node_name).qemu.post(**vm_config) + + # Update VM status + vm.status = VMStatus.STOPPED # Windows needs manual installation + vm.status_message = "Windows VM created. Ready for manual OS installation." + vm.deployed_at = datetime.utcnow() + self.db.commit() + + logger.info( + f"Successfully created Windows VM {vmid}. Manual installation required." + ) + return True + + except Exception as e: + logger.error(f"Failed to deploy Windows VM: {e}") + if vm: + vm.status = VMStatus.ERROR + vm.error_message = str(e) + self.db.commit() + return False + + def check_vm_status(self, vm_id: int) -> Optional[Dict[str, Any]]: + """Check VM status in Proxmox""" + try: + vm = self.db.query(VirtualMachine).filter(VirtualMachine.id == vm_id).first() + if not vm or not vm.vmid: + return None + + host = ( + self.db.query(ProxmoxHost) + .filter(ProxmoxHost.id == vm.proxmox_host_id) + .first() + ) + if not host: + return None + + node = self.db.query(ProxmoxNode).filter(ProxmoxNode.id == vm.node_id).first() + if not node: + return None + + proxmox = ProxmoxService(host) + status = proxmox.get_vm_status(node.node_name, vm.vmid) + + return status + + except Exception as e: + logger.error(f"Failed to check VM status: {e}") + return None + + def _deploy_from_cloud_image(self, vm, host, node, proxmox, vmid, cloud_image) -> bool: + """Deploy a VM from a cloud image using API-only approach (no SSH required)""" + try: + import time + from app.services.cloudinit import CloudInitService + + vm.status_message = f"Preparing cloud image: {cloud_image.name}..." + self.db.commit() + logger.info(f"Deploying VM {vmid} from cloud image {cloud_image.name}") + + # Use selected storage or default to local-lvm + storage = vm.storage if vm.storage else "local-lvm" + network_bridge = vm.network_bridge if vm.network_bridge else "vmbr0" + logger.info(f"Using storage: {storage}, network bridge: {network_bridge} for VM {vmid}") + + # Validate storage availability on target node + vm.status_message = "Validating storage availability..." + self.db.commit() + try: + node_storage = proxmox.proxmox.nodes(node.node_name).storage.get() + storage_names = [s['storage'] for s in node_storage if s.get('enabled', True)] + if storage not in storage_names: + available_storages = ', '.join(storage_names[:5]) + raise Exception( + f"Storage '{storage}' is not available on node '{node.node_name}'. " + f"Available storage: {available_storages}" + ) + logger.info(f"Validated storage '{storage}' exists on node '{node.node_name}'") + except Exception as e: + if "not available on node" in str(e) or "Storage" in str(e): + raise + logger.warning(f"Could not validate storage (continuing anyway): {e}") + + # FULLY AUTOMATED TEMPLATE-BASED APPROACH + # Automatically creates templates as needed, then clones instantly + import time + import subprocess + + # Make template VMID node-specific to avoid conflicts + # Node 1: 9001-9099, Node 2: 9101-9199, Node 3: 9201-9299, etc. + node_offset = (node.id - 1) * 100 + template_vmid = 9000 + node_offset + cloud_image.id + + logger.info(f"Using template VMID {template_vmid} for cloud image {cloud_image.id} on node {node.node_name} (node_id={node.id})") + + # Check if template exists ANYWHERE in the cluster + template_exists = False + template_node = None + + # Get all nodes in cluster + from app.models import ProxmoxNode + all_nodes = self.db.query(ProxmoxNode).filter(ProxmoxNode.host_id == host.id).all() + + # Check each node for ANY VM with this VMID (not just templates) + for check_node in all_nodes: + try: + # Try to get the VM config - this will raise exception if VM doesn't exist + vm_config = proxmox.proxmox.nodes(check_node.node_name).qemu(template_vmid).config.get() + + # VM exists on this node! + is_template = vm_config.get('template', 0) == 1 + logger.info(f"Found VM {template_vmid} on node {check_node.node_name}, is_template={is_template}") + + if check_node.node_name == node.node_name: + # VM is on correct node + if is_template: + # Perfect - it's already a template on the correct node! + template_exists = True + template_node = check_node.node_name + logger.info(f"Found existing template {template_vmid} on correct node {template_node}") + break + else: + # VM exists but is not a template - delete and recreate + logger.warning(f"VM {template_vmid} exists on correct node {check_node.node_name} but is NOT a template, deleting it...") + try: + proxmox.proxmox.nodes(check_node.node_name).qemu(template_vmid).delete() + logger.info(f"Deleted non-template VM {template_vmid} from {check_node.node_name}") + time.sleep(3) # Wait for deletion to complete + except Exception as del_err: + logger.error(f"Failed to delete non-template VM: {del_err}") + else: + # VM/template is on WRONG node - delete it to enforce node-specific VMID scheme + logger.warning(f"Found VM {template_vmid} on WRONG node {check_node.node_name} (expected {node.node_name}), deleting it...") + try: + proxmox.proxmox.nodes(check_node.node_name).qemu(template_vmid).delete() + logger.info(f"Deleted misplaced VM {template_vmid} from {check_node.node_name}") + time.sleep(3) # Wait for deletion to complete + except Exception as del_err: + logger.error(f"Failed to delete misplaced VM: {del_err}") + raise Exception(f"Cannot proceed: VM {template_vmid} exists on wrong node {check_node.node_name} and could not be deleted: {del_err}") + except Exception as e: + # VM doesn't exist on this node (or other error), continue checking + error_msg = str(e) + if "does not exist" not in error_msg.lower() and "not found" not in error_msg.lower(): + logger.debug(f"Error checking node {check_node.node_name} for VM {template_vmid}: {e}") + continue + + if not template_exists: + logger.info(f"Template {template_vmid} does not exist on any node in cluster, will create on {node.node_name}") + + # Create template automatically if needed + if not template_exists: + vm.status_message = f"Setting up cloud image (first time - takes ~5 min)..." + self.db.commit() + logger.info(f"Template {template_vmid} not found, creating automatically...") + + try: + # Create template using automated process + self._create_cloud_template_automated( + host=host, + node=node, + template_vmid=template_vmid, + cloud_image=cloud_image, + storage=storage + ) + logger.info(f"Template {template_vmid} created successfully") + + except Exception as e: + error_msg = str(e) + logger.error(f"Failed to create template: {error_msg}") + + if "Permission denied" in error_msg or "Host key verification failed" in error_msg: + raise Exception( + f"SSH access not configured. Please run this ONE-TIME setup command:\n\n" + f" sudo /tmp/enable_cloud_images.sh\n\n" + f"After that, cloud images will deploy automatically!" + ) + else: + raise Exception(f"Failed to create cloud image template: {error_msg}") + + # Clone template to create VM (pure API - instant!) + vm.status_message = f"Cloning cloud image template..." + self.db.commit() + + # Clone from the node where the template actually exists + source_node = template_node if template_node else node.node_name + logger.info(f"Cloning template {template_vmid} from node {source_node} to create VM {vmid} on node {node.node_name}") + + # Use retry helper with automatic lock cleanup + clone_result = self._retry_with_lock_cleanup( + lambda: proxmox.proxmox.nodes(source_node).qemu(template_vmid).clone.post( + newid=vmid, + name=vm.name, + full=1, # Full clone + target=node.node_name, # Clone to target node + storage=storage + ), + host=host, + node=node + ) + logger.info(f"Clone initiated: {clone_result}") + + # Wait for clone to complete + vm.status_message = "Cloning VM from template (copying disks)..." + self.db.commit() + time.sleep(5) + max_wait = 180 + waited = 0 + clone_completed = False + + while waited < max_wait: + try: + # Get VM config + vm_config = proxmox.proxmox.nodes(node.node_name).qemu(vmid).config.get() + + # Check if clone lock is still active + if 'lock' in vm_config and vm_config['lock'] == 'clone': + logger.info(f"VM {vmid} is still cloning (lock active), waiting...") + time.sleep(3) + waited += 3 + continue + + # Clone lock is gone, check for disk + if 'scsi0' in vm_config: + logger.info(f"VM {vmid} clone completed successfully, disk verified: scsi0={vm_config.get('scsi0')}") + clone_completed = True + break + else: + logger.warning(f"VM {vmid} clone lock released but no scsi0 disk found yet, waiting...") + time.sleep(3) + waited += 3 + + except Exception as e: + logger.debug(f"Error checking clone status: {e}") + time.sleep(3) + waited += 3 + + if not clone_completed: + logger.error(f"Clone timeout: VM {vmid} did not complete within {max_wait} seconds") + vm_config = proxmox.proxmox.nodes(node.node_name).qemu(vmid).config.get() + logger.error(f"Final VM config: {vm_config}") + raise Exception(f"VM {vmid} clone timeout. Clone did not complete within {max_wait} seconds.") + + # Customize VM resources + vm.status_message = "Customizing VM resources..." + self.db.commit() + + # Build config update with all options + config_update = { + 'cores': vm.cpu_cores, + 'sockets': vm.cpu_sockets, + 'memory': vm.memory + } + + # CPU options + if vm.cpu_type and vm.cpu_type != "host": + config_update['cpu'] = vm.cpu_type + if vm.cpu_flags: + cpu_val = config_update.get('cpu', 'host') + config_update['cpu'] = f"{cpu_val},{vm.cpu_flags}" + if vm.cpu_limit: + config_update['cpulimit'] = vm.cpu_limit + if vm.cpu_units: + config_update['cpuunits'] = vm.cpu_units + if vm.numa_enabled: + config_update['numa'] = 1 + + # Memory options + if vm.balloon is not None: + config_update['balloon'] = vm.balloon + if vm.shares: + config_update['shares'] = vm.shares + + # Hardware options + if vm.bios_type == "ovmf": + config_update['bios'] = 'ovmf' + if vm.machine_type and vm.machine_type != "pc": + config_update['machine'] = vm.machine_type + # Always apply VGA type to override Proxmox defaults (which may be spice) + if vm.vga_type: + config_update['vga'] = vm.vga_type + if vm.scsihw and vm.scsihw != "virtio-scsi-pci": + config_update['scsihw'] = vm.scsihw + + # Device options + if not vm.tablet: + config_update['tablet'] = 0 + if vm.hotplug: + config_update['hotplug'] = vm.hotplug + if vm.protection: + config_update['protection'] = 1 + if not vm.kvm: + config_update['kvm'] = 0 + if not vm.acpi: + config_update['acpi'] = 0 + if not vm.agent_enabled: + config_update['agent'] = 0 + + # Startup options + if vm.startup_order or vm.startup_up or vm.startup_down: + startup_parts = [] + if vm.startup_order: + startup_parts.append(f"order={vm.startup_order}") + if vm.startup_up: + startup_parts.append(f"up={vm.startup_up}") + if vm.startup_down: + startup_parts.append(f"down={vm.startup_down}") + config_update['startup'] = ','.join(startup_parts) + + # Description and tags + if vm.description: + config_update['description'] = vm.description + if vm.tags: + config_update['tags'] = vm.tags + + # ALWAYS set boot order using Proxmox 8.x format (order=device1;device2;...) + # Convert legacy format to new format + boot_order_map = { + 'cdn': 'scsi0;ide2;net0', # CD, Disk, Network -> Disk, CD, Network (disk should be first!) + 'dnc': 'scsi0;net0;ide2', # Disk, Network, CD + 'ncd': 'net0;scsi0;ide2', # Network, CD, Disk (for PXE boot) + 'ndc': 'net0;scsi0;ide2', # Network, Disk, CD + 'c': 'scsi0', # Disk only + 'd': 'scsi0', # Disk only (legacy) + 'n': 'net0', # Network only + } + + if vm.boot_order: + # If already in new format (contains semicolon or equals), use as-is + if ';' in vm.boot_order or '=' in vm.boot_order: + config_update['boot'] = vm.boot_order if '=' in vm.boot_order else f"order={vm.boot_order}" + else: + # Convert legacy format to new format + boot_devices = boot_order_map.get(vm.boot_order.lower(), 'scsi0;ide2;net0') + config_update['boot'] = f"order={boot_devices}" + logger.info(f"Converted boot order '{vm.boot_order}' to 'order={boot_devices}'") + else: + # Default: disk first for cloud images + config_update['boot'] = 'order=scsi0;net0' + logger.info(f"Using default boot order: order=scsi0;net0") + + # Set onboot (start at boot) if specified + if hasattr(vm, 'onboot') and vm.onboot is not None: + config_update['onboot'] = 1 if vm.onboot else 0 + logger.info(f"Setting onboot={config_update['onboot']}") + + # Use retry helper with automatic lock cleanup + self._retry_with_lock_cleanup( + lambda: proxmox.proxmox.nodes(node.node_name).qemu(vmid).config.put(**config_update), + host=host, + node=node, + proxmox=proxmox, + vmid=vmid + ) + + # Resize disk if needed + if vm.disk_size > 10: + logger.info(f"Resizing disk to {vm.disk_size}GB") + try: + self._retry_with_lock_cleanup( + lambda: proxmox.proxmox.nodes(node.node_name).qemu(vmid).resize.put( + disk='scsi0', + size=f'+{vm.disk_size - 10}G' + ), + host=host, + node=node, + proxmox=proxmox, + vmid=vmid + ) + time.sleep(2) + except Exception as e: + logger.warning(f"Disk resize: {e}") + + logger.info(f"VM {vmid} cloned and customized successfully") + + # Step 2: Configure cloud-init + vm.status_message = "Configuring cloud-init..." + self.db.commit() + + use_dhcp = not bool(vm.ip_address) + + # Prepare IP configuration for cloud-init + ip_config = None + if not use_dhcp: + # Format: ip=192.168.1.100/24,gw=192.168.1.1 + ip_config = f"ip={vm.ip_address}/{vm.netmask},gw={vm.gateway}" + + # Prepare nameserver + nameserver = None + if vm.dns_servers: + nameserver = vm.dns_servers.replace(",", " ") + + # Apply cloud-init config to VM + proxmox.configure_cloud_init( + node_name=node.node_name, + vmid=vmid, + user=vm.username, + password=vm.password, + ssh_keys=vm.ssh_key if vm.ssh_key else None, + ip_config=ip_config, + nameserver=nameserver + ) + + # Create custom cloud-init user-data snippet to install packages and enable SSH password auth + logger.info(f"Creating cloud-init user-data snippet for VM {vmid}") + try: + import subprocess + import tempfile + import os + + # Create user-data with user, packages and SSH configuration + # Create the specified user with proper home directory and password + user_data = f"""#cloud-config +users: + - name: {vm.username} + gecos: {vm.username} + sudo: ALL=(ALL) NOPASSWD:ALL + groups: users, admin, sudo + shell: /bin/bash + lock_passwd: false +chpasswd: + list: | + {vm.username}:{vm.password} + expire: false +disable_root: false +ssh_pwauth: true +package_update: true +package_upgrade: true +packages: + - qemu-guest-agent + - openssh-server +runcmd: + - systemctl enable qemu-guest-agent + - systemctl start qemu-guest-agent + - systemctl enable ssh + - systemctl start ssh + - sed -i 's/^#*PasswordAuthentication.*/PasswordAuthentication yes/' /etc/ssh/sshd_config + - sed -i 's/^#*PubkeyAuthentication.*/PubkeyAuthentication yes/' /etc/ssh/sshd_config + - sed -i 's/^#*PermitRootLogin.*/PermitRootLogin yes/' /etc/ssh/sshd_config + - systemctl restart ssh || systemctl restart sshd +""" + + # Create temporary file + with tempfile.NamedTemporaryFile(mode='w', delete=False, suffix='.yml') as f: + f.write(user_data) + temp_file = f.name + + # Get node IP + get_ip_cmd = f"ssh -o StrictHostKeyChecking=no root@{host.hostname} \"grep -A3 'name: {node.node_name}' /etc/pve/corosync.conf | grep ring0_addr | awk '{{print \\$2}}'\"" + ip_result = subprocess.run(get_ip_cmd, shell=True, capture_output=True, text=True) + + if ip_result.returncode == 0 and ip_result.stdout.strip(): + node_ip = ip_result.stdout.strip() + + # Create snippets directory on target node + snippet_path = f"/var/lib/vz/snippets/cloud-init-{vmid}.yml" + mkdir_cmd = f"ssh -o StrictHostKeyChecking=no root@{host.hostname} 'ssh -o StrictHostKeyChecking=no root@{node_ip} \"mkdir -p /var/lib/vz/snippets\"'" + mkdir_result = subprocess.run(mkdir_cmd, shell=True, capture_output=True, text=True) + + if mkdir_result.returncode != 0: + logger.error(f"Failed to create snippets directory: {mkdir_result.stderr}") + raise Exception(f"Failed to create snippets directory: {mkdir_result.stderr}") + + # Upload snippet file using ProxyJump to go directly to target node + scp_cmd = f"scp -o StrictHostKeyChecking=no -o ProxyJump=root@{host.hostname} {temp_file} root@{node_ip}:{snippet_path}" + scp_result = subprocess.run(scp_cmd, shell=True, capture_output=True, text=True) + + if scp_result.returncode != 0: + logger.error(f"Failed to upload snippet file: {scp_result.stderr}") + raise Exception(f"Failed to upload snippet file: {scp_result.stderr}") + + logger.info(f"Uploaded cloud-init snippet to {node_ip}:{snippet_path}") + + # Verify the file exists + verify_cmd = f"ssh -o StrictHostKeyChecking=no root@{host.hostname} 'ssh -o StrictHostKeyChecking=no root@{node_ip} \"test -f {snippet_path} && echo EXISTS\"'" + verify_result = subprocess.run(verify_cmd, shell=True, capture_output=True, text=True) + + if "EXISTS" not in verify_result.stdout: + logger.error(f"Snippet file verification failed - file does not exist at {snippet_path}") + raise Exception(f"Snippet file not found at {snippet_path}") + + logger.info(f"Verified snippet file exists at {snippet_path}") + + # Apply the custom user-data snippet + # Note: Snippets must be on directory-based storage (local), not LVM + proxmox.proxmox.nodes(node.node_name).qemu(vmid).config.put( + cicustom=f"user=local:snippets/cloud-init-{vmid}.yml" + ) + logger.info(f"Applied custom cloud-init user-data for VM {vmid}") + else: + logger.error(f"Failed to get node IP for {node.node_name}") + raise Exception(f"Failed to get node IP for {node.node_name}") + + os.unlink(temp_file) + except Exception as e: + logger.warning(f"Could not create custom cloud-init snippet: {e}") + + # IMPORTANT: Regenerate cloud-init drive after configuration + # This ensures the new cloud-init settings are applied + logger.info(f"Regenerating cloud-init drive for VM {vmid}") + try: + # Delete and recreate the cloud-init drive to apply new config + self._retry_with_lock_cleanup( + lambda: proxmox.proxmox.nodes(node.node_name).qemu(vmid).config.put( + ide2=f"{storage}:cloudinit" + ), + host=host, + node=node, + proxmox=proxmox, + vmid=vmid + ) + time.sleep(1) + except Exception as e: + logger.warning(f"Failed to regenerate cloud-init drive: {e}") + + # Step 3: Start the VM + vm.status_message = "Starting VM..." + self.db.commit() + logger.info(f"Starting VM {vmid}") + + start_success = proxmox.start_vm(node.node_name, vmid) + if not start_success: + logger.warning(f"Failed to start VM {vmid}, but VM was created successfully") + + # Update VM status + vm.status = VMStatus.RUNNING + vm.status_message = f"VM {vmid} deployed successfully from cloud image!" + vm.deployed_at = datetime.utcnow() + self.db.commit() + + logger.info(f"Successfully deployed VM {vmid} from cloud image {cloud_image.name}") + return True + + except Exception as e: + logger.error(f"Failed to deploy VM from cloud image: {e}", exc_info=True) + vm.status = VMStatus.ERROR + vm.status_message = "Deployment failed" + vm.error_message = str(e) + self.db.commit() + return False + + def _create_cloud_template_automated(self, host, node, template_vmid, cloud_image, storage): + """ + Automatically create a cloud image template on Proxmox via SSH + This runs automatically when needed - no manual steps required + """ + import subprocess + + logger.info(f"Creating cloud image template {template_vmid} on node {node.node_name} automatically...") + + # Download cloud image if needed + cloud_image_path = cloud_image.storage_path or f"/var/lib/depl0y/cloud-images/{cloud_image.filename}" + + if not cloud_image.is_downloaded: + logger.info(f"Downloading {cloud_image.name} from {cloud_image.download_url}") + import os + os.makedirs(os.path.dirname(cloud_image_path), exist_ok=True) + + download_cmd = f"wget -q -O {cloud_image_path} {cloud_image.download_url}" + result = subprocess.run(download_cmd, shell=True, capture_output=True) + if result.returncode != 0: + raise Exception(f"Failed to download cloud image: {result.stderr.decode()}") + + cloud_image.is_downloaded = True + cloud_image.download_status = 'completed' + cloud_image.download_progress = 100 + self.db.commit() + logger.info(f"Downloaded to {cloud_image_path}") + + # SSH to cluster host and execute commands that target the specific node + # Use pvesh or qm commands directly on cluster host with node parameter + ssh_host = f"root@{host.hostname}" + target_node = node.node_name + logger.info(f"SSH target: {ssh_host}, will create template on node: {target_node}") + + # Step 1: Create VM (delete first if it exists but isn't a template) + logger.info(f"Creating VM {template_vmid} on Proxmox...") + + # All commands executed via SSH to cluster host + # Commands target specific node using -node parameter or pvesh + + # Check if VM exists - if it's already a template, we're done + # Use pvesh to check if template exists on the target node + check_template = f"ssh -o StrictHostKeyChecking=no -o BatchMode=yes {ssh_host} 'pvesh get /nodes/{target_node}/qemu/{template_vmid}/status/current >/dev/null 2>&1 && pvesh get /nodes/{target_node}/qemu/{template_vmid}/config | grep -q \"^template: 1\"'" + check_result = subprocess.run(check_template, shell=True, capture_output=True) + if check_result.returncode == 0: + logger.info(f"Template {template_vmid} already exists on {target_node}, skipping creation") + return + + # Delete if VM exists but isn't a template + cleanup_script = f""" +ssh -o StrictHostKeyChecking=no -o BatchMode=yes {ssh_host} ' +if pvesh get /nodes/{target_node}/qemu/{template_vmid}/status/current >/dev/null 2>&1; then + echo VM {template_vmid} exists but is not a template, deleting... + pvesh delete /nodes/{target_node}/qemu/{template_vmid} --purge=1 || true +fi +' +""" + subprocess.run(cleanup_script, shell=True, capture_output=True) + + # Create new VM on specific node using pvesh + create_vm_script = f""" +ssh -o StrictHostKeyChecking=no -o BatchMode=yes {ssh_host} ' +pvesh create /nodes/{target_node}/qemu \\ + -vmid {template_vmid} \\ + -name {cloud_image.name.replace(' ', '-')} \\ + -memory 2048 \\ + -cores 2 \\ + -net0 virtio,bridge=vmbr0 \\ + -vga qxl \\ + -ostype l26 \\ + -scsihw virtio-scsi-pci \\ + -agent enabled=1 +' +""" + result = subprocess.run(create_vm_script, shell=True, capture_output=True) + if result.returncode != 0: + raise Exception(f"Failed to create VM on {target_node}: {result.stderr.decode()}") + + # Step 2: Upload cloud image to the cluster host + # In a Proxmox cluster, storage is typically shared or accessible via the cluster + # We upload to the cluster host and execute commands there that target the specific node + logger.info(f"Uploading cloud image to cluster host...") + upload_script = f""" +scp -o StrictHostKeyChecking=no -o BatchMode=yes {cloud_image_path} {ssh_host}:/tmp/{cloud_image.filename} +""" + result = subprocess.run(upload_script, shell=True, capture_output=True) + if result.returncode != 0: + raise Exception(f"Failed to upload to cluster host: {result.stderr.decode()}") + + # Step 3: Get node IP address from corosync config + logger.info(f"Getting IP address for node {target_node}...") + get_ip_cmd = f""" +ssh -o StrictHostKeyChecking=no -o BatchMode=yes {ssh_host} "grep -A3 'name: {target_node}' /etc/pve/corosync.conf | grep ring0_addr | awk '{{print \\$2}}'" +""" + ip_result = subprocess.run(get_ip_cmd, shell=True, capture_output=True, text=True) + if ip_result.returncode != 0 or not ip_result.stdout.strip(): + raise Exception(f"Failed to get IP for node {target_node}") + + node_ip = ip_result.stdout.strip() + logger.info(f"Node {target_node} IP: {node_ip}") + + # Step 4: Import disk and configure on specific node + # qm importdisk MUST run on the node where the VM exists + logger.info(f"Importing disk and configuring template on {target_node}...") + + # Use node IP for SSH instead of hostname + import_script = f""" +ssh -o StrictHostKeyChecking=no -o BatchMode=yes {ssh_host} ' +# Copy image file to target node +scp -o StrictHostKeyChecking=no /tmp/{cloud_image.filename} root@{node_ip}:/tmp/{cloud_image.filename} + +# Run qm importdisk ON the target node (needs local VM config) +# This outputs: "Successfully imported disk as unused0" +echo "Importing disk to {storage}..." +ssh -o StrictHostKeyChecking=no root@{node_ip} "qm importdisk {template_vmid} /tmp/{cloud_image.filename} {storage} --format qcow2" + +# Attach the imported disk (qm importdisk puts it in unused0) +# Auto-detect the disk format from unused0 to handle both LVM and directory storage +echo "Detecting disk format and attaching to scsi0..." +DISK_PATH=$(ssh -o StrictHostKeyChecking=no root@{node_ip} "qm config {template_vmid} | grep unused0 | awk -F: '"'"'{{print \\$2\":\"\\$3}}'"'"' | tr -d ''''[:space:]''''") +echo "Detected disk path: $DISK_PATH" +ssh -o StrictHostKeyChecking=no root@{node_ip} "qm set {template_vmid} --scsi0 $DISK_PATH" + +# Configure boot order and cloud-init using pvesh on cluster host +echo "Configuring boot order and cloud-init..." +pvesh set /nodes/{target_node}/qemu/{template_vmid}/config -boot order=scsi0 +pvesh set /nodes/{target_node}/qemu/{template_vmid}/config -ide2 {storage}:cloudinit + +# Cleanup and convert to template +echo "Converting to template..." +ssh -o StrictHostKeyChecking=no root@{node_ip} "rm -f /tmp/{cloud_image.filename}" +rm -f /tmp/{cloud_image.filename} +pvesh create /nodes/{target_node}/qemu/{template_vmid}/template +echo "Template {template_vmid} creation complete!" +' +""" + result = subprocess.run(import_script, shell=True, capture_output=True, timeout=300, text=True) + + # Log the full output for debugging + if result.stdout: + logger.info(f"Template creation output:\n{result.stdout}") + if result.stderr: + logger.warning(f"Template creation warnings:\n{result.stderr}") + + if result.returncode != 0: + error_details = f"Return code: {result.returncode}\nStdout: {result.stdout}\nStderr: {result.stderr}" + logger.error(f"Failed to import disk on {target_node}: {error_details}") + raise Exception(f"Failed to import disk on {target_node}: {result.stderr}") + + logger.info(f"Template {template_vmid} created successfully on node {target_node}!") + + def _ensure_ssh_access(self, host) -> bool: + """ + Ensure SSH key access is configured for the Proxmox host + Automatically attempts to configure if not already set up + + Args: + host: ProxmoxHost model instance + + Returns: + True if SSH access is configured, False otherwise + """ + import subprocess + + ssh_pub_key_path = "/opt/depl0y/.ssh/id_rsa.pub" + + # Check if SSH key already works + try: + logger.info(f"Checking SSH access to {host.hostname}...") + result = subprocess.run( + [ + 'ssh', '-o', 'StrictHostKeyChecking=no', + '-o', 'UserKnownHostsFile=/dev/null', + '-o', 'BatchMode=yes', + '-o', 'ConnectTimeout=5', + f'root@{host.hostname}', + 'echo', 'SSH_KEY_CONFIGURED' + ], + capture_output=True, + timeout=10 + ) + + if result.returncode == 0 and b'SSH_KEY_CONFIGURED' in result.stdout: + logger.info(f"✓ SSH key already configured for {host.hostname}") + return True + + logger.info(f"SSH key not yet configured for {host.hostname}") + + except Exception as e: + logger.debug(f"SSH key check failed: {e}") + + # Try to configure SSH key automatically using password + if not host.password: + logger.warning(f"No password stored for Proxmox host {host.hostname}. Cannot automatically configure SSH.") + logger.info(f"To enable automatic SSH setup, please add the root password for {host.hostname} in the Proxmox Hosts settings.") + return False + + logger.info(f"Attempting automatic SSH key setup for {host.hostname}...") + + try: + # Read the public key + with open(ssh_pub_key_path, 'r') as f: + public_key = f.read().strip() + + # Use sshpass to copy the key + result = subprocess.run( + [ + 'sshpass', '-p', host.password, + 'ssh-copy-id', + '-o', 'StrictHostKeyChecking=no', + '-o', 'UserKnownHostsFile=/dev/null', + '-i', ssh_pub_key_path, + f'root@{host.hostname}' + ], + capture_output=True, + timeout=30 + ) + + if result.returncode == 0: + logger.info(f"✓ SSH key automatically configured for {host.hostname}") + return True + else: + stderr = result.stderr.decode() if result.stderr else "" + logger.warning(f"Failed to copy SSH key: {stderr}") + + # Try alternative method - direct SSH command + ssh_command = f"mkdir -p ~/.ssh && chmod 700 ~/.ssh && echo '{public_key}' >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys" + + result = subprocess.run( + [ + 'sshpass', '-p', host.password, + 'ssh', + '-o', 'StrictHostKeyChecking=no', + '-o', 'UserKnownHostsFile=/dev/null', + f'root@{host.hostname}', + ssh_command + ], + capture_output=True, + timeout=30 + ) + + if result.returncode == 0: + logger.info(f"✓ SSH key automatically configured for {host.hostname} (alternative method)") + return True + + except Exception as e: + logger.error(f"Failed to automatically configure SSH: {e}") + + # Provide helpful error message + if not host.password: + logger.error(f"Could not configure SSH for {host.hostname}: No password stored") + logger.info(f"To enable cloud image support, you must either:") + logger.info(f"1. Add the Proxmox root password in Settings > Proxmox Hosts > Edit Host") + logger.info(f"2. OR manually configure SSH key by running on this server:") + logger.info(f" cat /opt/depl0y/.ssh/id_rsa.pub | ssh root@{host.hostname} 'cat >> ~/.ssh/authorized_keys'") + else: + logger.warning(f"Could not automatically configure SSH for {host.hostname}") + + return False + + def _create_cloud_template_auto( + self, + proxmox: ProxmoxService, + node_name: str, + host_hostname: str, + template_vmid: int, + cloud_image, + storage: str + ) -> None: + """ + Automatically create a cloud image template via SSH + + This method: + 1. Downloads the cloud image file from the internet to Proxmox + 2. Creates a VM template with the cloud image + 3. Configures it for cloud-init + + Args: + proxmox: ProxmoxService instance + node_name: Name of the Proxmox node + host_hostname: Hostname/IP of the Proxmox host + template_vmid: VMID to use for the template + cloud_image: CloudImage model instance + storage: Storage pool to use + """ + import subprocess + + logger.info(f"Creating cloud template {template_vmid} for {cloud_image.name} via SSH") + + # Map cloud image names to their download URLs + cloud_image_urls = { + "Ubuntu 24.04 LTS": "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img", + "Ubuntu 22.04 LTS": "https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-amd64.img", + "Ubuntu 20.04 LTS": "https://cloud-images.ubuntu.com/focal/current/focal-server-cloudimg-amd64.img", + "Debian 12": "https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-generic-amd64.qcow2", + "Debian 11": "https://cloud.debian.org/images/cloud/bullseye/latest/debian-11-generic-amd64.qcow2", + } + + image_url = cloud_image_urls.get(cloud_image.name) + if not image_url: + raise Exception(f"Unknown cloud image: {cloud_image.name}") + + # Construct the SSH command to create the template + template_name = cloud_image.name.lower().replace(' ', '-').replace('.', '-') + image_filename = image_url.split('/')[-1] + + # Create a script that will be executed on the Proxmox host + script = f"""#!/bin/bash +set -e + +echo "Creating cloud image template {template_vmid} for {cloud_image.name}" + +# Check if template already exists +if qm status {template_vmid} &>/dev/null 2>&1; then + echo "Template {template_vmid} already exists" + exit 0 +fi + +# Download cloud image to Proxmox +cd /var/lib/vz/template/iso +if [ ! -f "{image_filename}" ]; then + echo "Downloading cloud image..." + wget -q --show-progress "{image_url}" -O {image_filename} +fi + +# Create VM +echo "Creating VM {template_vmid}..." +qm create {template_vmid} \\ + --name "{template_name}" \\ + --memory 2048 \\ + --cores 2 \\ + --net0 virtio,bridge=vmbr0 \\ + --scsihw virtio-scsi-pci + +# Import disk +echo "Importing disk..." +qm importdisk {template_vmid} {image_filename} {storage} + +# Configure VM +echo "Configuring VM..." +qm set {template_vmid} --scsi0 {storage}:vm-{template_vmid}-disk-0 +qm set {template_vmid} --ide2 {storage}:cloudinit +qm set {template_vmid} --boot order=scsi0 +qm set {template_vmid} --serial0 socket --vga serial0 +qm set {template_vmid} --agent enabled=1 + +# Convert to template +echo "Converting to template..." +qm template {template_vmid} + +echo "Template {template_vmid} created successfully" +""" + + try: + # Execute the script via SSH + ssh_command = [ + 'ssh', + '-o', 'StrictHostKeyChecking=no', + '-o', 'UserKnownHostsFile=/dev/null', + '-o', 'BatchMode=yes', + '-o', 'ConnectTimeout=10', + f'root@{host_hostname}', + 'bash', '-s' + ] + + # Run as depl0y user + result = subprocess.run( + ['sudo', '-u', 'depl0y'] + ssh_command, + input=script.encode(), + capture_output=True, + timeout=600 # 10 minute timeout + ) + + if result.returncode != 0: + stderr = result.stderr.decode() + stdout = result.stdout.decode() + logger.error(f"SSH command failed: {stderr}") + logger.error(f"stdout: {stdout}") + raise Exception(f"Failed to create template via SSH: {stderr}") + + logger.info(f"Template {template_vmid} created successfully") + logger.info(result.stdout.decode()) + + except subprocess.TimeoutExpired: + raise Exception(f"Template creation timed out after 10 minutes") + except Exception as e: + logger.error(f"Error creating template: {e}") + raise + + def delete_vm(self, vm_id: int) -> bool: + """Delete a VM""" + try: + vm = self.db.query(VirtualMachine).filter(VirtualMachine.id == vm_id).first() + if not vm: + logger.error(f"VM {vm_id} not found") + return False + + if vm.vmid: + host = ( + self.db.query(ProxmoxHost) + .filter(ProxmoxHost.id == vm.proxmox_host_id) + .first() + ) + node = self.db.query(ProxmoxNode).filter(ProxmoxNode.id == vm.node_id).first() + + if host and node: + proxmox = ProxmoxService(host) + + # Stop VM first + proxmox.stop_vm(node.node_name, vm.vmid) + time.sleep(2) + + # Delete VM + proxmox.delete_vm(node.node_name, vm.vmid) + + # Delete from database + self.db.delete(vm) + self.db.commit() + + logger.info(f"Successfully deleted VM {vm_id}") + return True + + except Exception as e: + logger.error(f"Failed to delete VM: {e}") + return False + + def _create_cloud_template( + self, + proxmox: ProxmoxService, + node_name: str, + template_vmid: int, + cloud_image, + storage: str + ) -> None: + """ + Automatically create a cloud image template on a Proxmox node + + Args: + proxmox: ProxmoxService instance + node_name: Name of the Proxmox node + template_vmid: VMID to use for the template + cloud_image: CloudImage model instance + storage: Storage pool to use + """ + import requests + import os + import subprocess + import tempfile + + logger.info(f"Creating cloud template {template_vmid} for {cloud_image.name}") + + # Step 1: Download cloud image if not already downloaded + if not cloud_image.is_downloaded or not cloud_image.storage_path: + logger.info(f"Downloading cloud image {cloud_image.name}") + from app.services.cloud_images import download_cloud_image_task + download_cloud_image_task(cloud_image.id, self.db) + + # Wait for download to complete + max_wait = 1800 # 30 minutes max + waited = 0 + while not cloud_image.is_downloaded and waited < max_wait: + time.sleep(5) + waited += 5 + self.db.refresh(cloud_image) + + if not cloud_image.is_downloaded: + raise Exception(f"Cloud image download timed out after {max_wait} seconds") + + logger.info(f"Cloud image available at: {cloud_image.storage_path}") + + # Step 2: Upload image to Proxmox node and create template via SSH + # We need to use SSH to run qm importdisk command + from app.models import ProxmoxHost + host = self.db.query(ProxmoxHost).filter( + ProxmoxHost.id == proxmox.host.id + ).first() + + if not host: + raise Exception("Proxmox host not found") + + try: + # Use subprocess to SSH and create the template + # This assumes SSH key authentication is configured + + # Create a temporary script to run on Proxmox + script_content = f"""#!/bin/bash +set -e + +# Upload cloud image to Proxmox +TEMP_IMG="/tmp/cloud-image-{template_vmid}.img" + +# Check if template already exists +if qm status {template_vmid} &>/dev/null; then + echo "Template {template_vmid} already exists" + exit 0 +fi + +# Create VM shell +qm create {template_vmid} \\ + --name "{cloud_image.name.lower().replace(' ', '-')}" \\ + --memory 2048 \\ + --cores 2 \\ + --net0 virtio,bridge=vmbr0 \\ + --scsihw virtio-scsi-pci + +# Import the disk (we'll upload the image file first) +qm importdisk {template_vmid} "$TEMP_IMG" {storage} + +# Attach the disk +qm set {template_vmid} --scsi0 {storage}:vm-{template_vmid}-disk-0 + +# Add cloud-init drive +qm set {template_vmid} --ide2 {storage}:cloudinit + +# Set boot order +qm set {template_vmid} --boot order=scsi0 + +# Enable serial console +qm set {template_vmid} --serial0 socket --vga serial0 + +# Enable QEMU guest agent +qm set {template_vmid} --agent enabled=1 + +# Convert to template +qm template {template_vmid} + +# Clean up +rm -f "$TEMP_IMG" + +echo "Template {template_vmid} created successfully" +""" + + # Upload the cloud image file to Proxmox + logger.info(f"Uploading cloud image to Proxmox node {node_name}") + + # Use scp to copy the file + subprocess.run( + [ + "scp", + "-o", "StrictHostKeyChecking=no", + "-o", "UserKnownHostsFile=/dev/null", + cloud_image.storage_path, + f"root@{host.hostname}:/tmp/cloud-image-{template_vmid}.img" + ], + check=True, + capture_output=True, + timeout=600 # 10 minute timeout for upload + ) + + logger.info(f"Cloud image uploaded, creating template via SSH") + + # Run the script via SSH + result = subprocess.run( + [ + "ssh", + "-o", "StrictHostKeyChecking=no", + "-o", "UserKnownHostsFile=/dev/null", + f"root@{host.hostname}", + script_content + ], + check=True, + capture_output=True, + text=True, + timeout=300 # 5 minute timeout + ) + + logger.info(f"Template creation output: {result.stdout}") + + if result.returncode != 0: + raise Exception(f"Failed to create template: {result.stderr}") + + logger.info(f"Successfully created template {template_vmid}") + + except subprocess.TimeoutExpired as e: + raise Exception(f"Template creation timed out: {e}") + except subprocess.CalledProcessError as e: + error_msg = f"SSH command failed: {e.stderr if e.stderr else str(e)}" + logger.error(error_msg) + raise Exception(error_msg) + except Exception as e: + logger.error(f"Failed to create cloud template via SSH: {e}") + raise diff --git a/src/backend/app/services/proxmox.py b/src/backend/app/services/proxmox.py new file mode 100644 index 0000000..22eab77 --- /dev/null +++ b/src/backend/app/services/proxmox.py @@ -0,0 +1,854 @@ +"""Proxmox VE API integration service""" +from typing import List, Dict, Optional, Any +from proxmoxer import ProxmoxAPI +from proxmoxer.core import ResourceException +from sqlalchemy.orm import Session +from app.models import ProxmoxHost, ProxmoxNode +from app.core.security import decrypt_data +from datetime import datetime +import logging + +logger = logging.getLogger(__name__) + + +class ProxmoxService: + """Service for interacting with Proxmox VE""" + + def __init__(self, host: ProxmoxHost): + """Initialize Proxmox connection""" + self.host = host + + # Check if using API token (preferred for 2FA-enabled Proxmox) + if host.api_token_id and host.api_token_secret: + try: + token_secret = decrypt_data(host.api_token_secret) + except Exception: + token_secret = host.api_token_secret + + # Extract token name and user from token ID + # Token ID can be "tokenname" or "user@realm!tokenname" + if '!' in host.api_token_id: + token_parts = host.api_token_id.split('!') + token_user = token_parts[0] # e.g., "root@pam" + token_name = token_parts[1] # e.g., "depl0y" + else: + token_user = host.username + token_name = host.api_token_id + + logger.info(f"Connecting to Proxmox {host.hostname} with token auth: user={token_user}, token_name={token_name}") + + self.proxmox = ProxmoxAPI( + host.hostname, + user=token_user, + token_name=token_name, + token_value=token_secret, + port=host.port, + verify_ssl=host.verify_ssl, + timeout=30, # Increase timeout to 30 seconds for slow operations + ) + else: + # Fall back to password authentication + try: + password = decrypt_data(host.password) + except Exception: + password = host.password + + self.proxmox = ProxmoxAPI( + host.hostname, + user=host.username, + password=password, + port=host.port, + verify_ssl=host.verify_ssl, + timeout=30, # Increase timeout to 30 seconds for slow operations + ) + + def test_connection(self) -> bool: + """Test connection to Proxmox host""" + try: + version = self.proxmox.version.get() + logger.info(f"Successfully connected to Proxmox {self.host.name}, version: {version.get('version', 'unknown')}") + return True + except Exception as e: + logger.error(f"Failed to connect to Proxmox host {self.host.name}: {str(e)}") + logger.error(f"Connection details: hostname={self.host.hostname}, port={self.host.port}, using_token={bool(self.host.api_token_id)}") + return False + + def get_nodes(self) -> List[Dict[str, Any]]: + """Get list of nodes from Proxmox cluster""" + try: + nodes = self.proxmox.nodes.get() + return nodes + except ResourceException as e: + logger.error(f"Failed to get nodes: {e}") + return [] + + def get_node_resources(self, node_name: str) -> Optional[Dict[str, Any]]: + """Get resource information for a specific node""" + try: + node = self.proxmox.nodes(node_name) + status = node.status.get() + + # Proxmox returns 'online' or 'offline' in the node list, not in status + # We can infer online if we get a successful response + node_status = "online" + + return { + "node_name": node_name, + "status": node_status, + "cpu_cores": status.get("cpuinfo", {}).get("cpus", 0), + "cpu_usage": int((status.get("cpu", 0) * 100)), + "memory_total": status.get("memory", {}).get("total", 0), + "memory_used": status.get("memory", {}).get("used", 0), + "disk_total": status.get("rootfs", {}).get("total", 0), + "disk_used": status.get("rootfs", {}).get("used", 0), + "uptime": status.get("uptime", 0), + } + except ResourceException as e: + logger.error(f"Failed to get node resources for {node_name}: {e}") + return None + + def get_next_vmid(self) -> int: + """Get next available VMID""" + try: + return int(self.proxmox.cluster.nextid.get()) + except ResourceException as e: + logger.error(f"Failed to get next VMID: {e}") + return 100 + + def get_storage_list(self, node_name: str) -> List[Dict[str, Any]]: + """Get storage list for a node with detailed information""" + try: + storages = self.proxmox.nodes(node_name).storage.get() + detailed_storages = [] + + for storage in storages: + storage_name = storage.get('storage') + try: + # Get detailed storage info + storage_status = self.proxmox.nodes(node_name).storage(storage_name).status.get() + detailed_storages.append({ + 'storage': storage_name, + 'type': storage.get('type'), + 'content': storage.get('content', ''), + 'active': storage.get('active', 0) == 1, + 'enabled': storage.get('enabled', 0) == 1, + 'total': storage_status.get('total', 0), + 'used': storage_status.get('used', 0), + 'available': storage_status.get('avail', 0), + 'shared': storage.get('shared', 0) == 1, + }) + except Exception as e: + logger.warning(f"Failed to get detailed info for storage {storage_name}: {e}") + detailed_storages.append({ + 'storage': storage_name, + 'type': storage.get('type'), + 'content': storage.get('content', ''), + 'active': storage.get('active', 0) == 1, + 'enabled': storage.get('enabled', 0) == 1, + 'total': 0, + 'used': 0, + 'available': 0, + 'shared': storage.get('shared', 0) == 1, + }) + + return detailed_storages + except ResourceException as e: + logger.error(f"Failed to get storage list: {e}") + return [] + + def get_network_interfaces(self, node_name: str) -> List[Dict[str, Any]]: + """Get network interfaces/bridges for a node""" + try: + network = self.proxmox.nodes(node_name).network.get() + bridges = [] + + for iface in network: + iface_type = iface.get('type') + # Include bridges and bonds + if iface_type in ['bridge', 'bond', 'OVSBridge']: + bridges.append({ + 'iface': iface.get('iface'), + 'type': iface_type, + 'active': iface.get('active', 0) == 1, + 'autostart': iface.get('autostart', 0) == 1, + 'address': iface.get('address'), + 'netmask': iface.get('netmask'), + 'gateway': iface.get('gateway'), + 'bridge_ports': iface.get('bridge_ports'), + 'comments': iface.get('comments'), + }) + + return bridges + except ResourceException as e: + logger.error(f"Failed to get network interfaces: {e}") + return [] + + def iso_exists_on_storage(self, node_name: str, storage: str, filename: str) -> bool: + """Check if ISO exists on Proxmox storage""" + try: + # List all ISOs in the storage + content = self.proxmox.nodes(node_name).storage(storage).content.get(content='iso') + logger.info(f"Found {len(content)} ISO(s) in {storage} on {node_name}") + + # Check if our ISO exists + target_volid = f"{storage}:iso/{filename}" + for item in content: + volid = item.get('volid') + logger.debug(f"Checking ISO: {volid}") + if volid == target_volid: + logger.info(f"ISO found on Proxmox: {volid}") + return True + + logger.info(f"ISO not found on Proxmox. Looking for: {target_volid}") + return False + except Exception as e: + logger.error(f"Failed to check ISO existence: {e}") + return False + + def upload_iso(self, node_name: str, storage: str, iso_path: str, filename: str, progress_callback=None) -> bool: + """Upload ISO to Proxmox storage with real-time progress tracking using direct requests""" + import os + import time + import requests + from requests.auth import AuthBase + + class TokenAuth(AuthBase): + """Custom authentication for Proxmox API tokens""" + def __init__(self, token_id, token_secret): + self.token_id = token_id + self.token_secret = token_secret + + def __call__(self, r): + r.headers['Authorization'] = f'PVEAPIToken={self.token_id}={self.token_secret}' + return r + + class ProgressFileWrapper: + """Wrapper for file object that tracks upload progress""" + def __init__(self, file_obj, file_size, callback): + self.file_obj = file_obj + self.file_size = file_size + self.callback = callback + self.bytes_uploaded = 0 + self.start_time = time.time() + self.last_update = time.time() + + def read(self, size=-1): + chunk = self.file_obj.read(size) + if chunk: + self.bytes_uploaded += len(chunk) + + # Update progress every 0.5 seconds + now = time.time() + if now - self.last_update >= 0.5 or self.bytes_uploaded == self.file_size: + percent = int((self.bytes_uploaded / self.file_size) * 100) + elapsed = now - self.start_time + mb_uploaded = self.bytes_uploaded / (1024 * 1024) + speed_mbps = mb_uploaded / elapsed if elapsed > 0 else 0 + + # Calculate ETA + if speed_mbps > 0: + mb_remaining = (self.file_size - self.bytes_uploaded) / (1024 * 1024) + eta_seconds = mb_remaining / speed_mbps + eta_str = f" - ETA {int(eta_seconds)}s" if eta_seconds > 1 else "" + else: + eta_str = "" + + if self.callback: + if percent >= 100: + # At 100%, let user know we're waiting for Proxmox to finish processing + self.callback(100, "Upload transferred! Waiting for Proxmox to write to disk...") + else: + self.callback( + percent, + f"Uploading: {percent}% ({mb_uploaded:.1f}/{self.file_size/(1024*1024):.1f} MB @ {speed_mbps:.1f} MB/s{eta_str})" + ) + + self.last_update = now + + return chunk + + def __len__(self): + return self.file_size + + def __iter__(self): + return self + + def __next__(self): + chunk = self.read(8192) + if not chunk: + raise StopIteration + return chunk + + try: + # Get file size for progress calculation + file_size = os.path.getsize(iso_path) + file_size_mb = file_size / (1024 * 1024) + logger.info(f"Uploading ISO {filename} ({file_size_mb:.1f} MB) to {node_name}:{storage}") + + if progress_callback: + progress_callback(0, f"Starting upload of {filename} ({file_size_mb:.1f} MB)...") + + start_time = time.time() + + # Build the upload URL + url = f"https://{self.host.hostname}:{self.host.port}/api2/json/nodes/{node_name}/storage/{storage}/upload" + logger.info(f"Upload URL: {url}") + + # Prepare authentication + auth = None + if self.host.api_token_id and self.host.api_token_secret: + # Use API token authentication + try: + token_secret = decrypt_data(self.host.api_token_secret) + except Exception: + token_secret = self.host.api_token_secret + + # Extract full token ID (user@realm!tokenname) + if '!' in self.host.api_token_id: + full_token_id = self.host.api_token_id + else: + full_token_id = f"{self.host.username}!{self.host.api_token_id}" + + auth = TokenAuth(full_token_id, token_secret) + logger.info(f"Using token authentication: {full_token_id}") + else: + # Use password authentication + try: + password = decrypt_data(self.host.password) + except Exception: + password = self.host.password + auth = (self.host.username, password) + logger.info(f"Using password authentication") + + # Upload the file with progress tracking + logger.info(f"Starting ISO upload to Proxmox...") + with open(iso_path, 'rb') as iso_file: + wrapped_file = ProgressFileWrapper(iso_file, file_size, progress_callback) + + # Prepare multipart form data + files = { + 'filename': (filename, wrapped_file, 'application/octet-stream') + } + data = { + 'content': 'iso' + } + + try: + logger.info(f"Calling Proxmox upload API with requests...") + response = requests.post( + url, + auth=auth, + files=files, + data=data, + verify=self.host.verify_ssl, + timeout=3600 # 1 hour timeout for large uploads + ) + logger.info(f"Upload response status: {response.status_code}") + logger.info(f"Upload response: {response.text}") + + if response.status_code != 200: + raise Exception(f"Upload failed with status {response.status_code}: {response.text}") + + except Exception as post_error: + logger.error(f"Upload POST call failed: {post_error}", exc_info=True) + raise + + # POST has returned - Proxmox has finished writing to disk + logger.info(f"Proxmox upload POST completed") + + elapsed = time.time() - start_time + speed_mbps = file_size_mb / elapsed if elapsed > 0 else 0 + + if progress_callback: + progress_callback(100, f"Upload complete! ({speed_mbps:.1f} MB/s average)") + + logger.info(f"Successfully uploaded ISO {filename} to {node_name}:{storage} in {elapsed:.1f}s ({speed_mbps:.1f} MB/s)") + return True + except Exception as e: + logger.error(f"Failed to upload ISO: {e}") + if progress_callback: + progress_callback(0, f"Upload failed: {str(e)}") + return False + + def create_vm( + self, + node_name: str, + vmid: int, + name: str, + cores: int, + memory: int, + disk_size: int, + storage: str = "local-lvm", + iso: Optional[str] = None, + network_bridge: str = "vmbr0", + sockets: int = 1, + # Advanced options + cpu_type: str = "host", + cpu_flags: Optional[str] = None, + numa_enabled: bool = False, + bios_type: str = "seabios", + machine_type: str = "pc", + vga_type: str = "std", + boot_order: str = "cdn", + network_interfaces: Optional[list] = None, + ) -> bool: + """Create a new VM with advanced options""" + try: + # Base VM configuration + vm_config = { + "vmid": vmid, + "name": name, + "sockets": sockets, + "cores": cores, + "memory": memory, + "scsihw": "virtio-scsi-pci", + "scsi0": f"{storage}:{disk_size}", + "net0": f"virtio,bridge={network_bridge}", + "ostype": "l26", # Linux 2.6+ + "agent": 1, # Enable QEMU guest agent + } + + # Advanced CPU options + if cpu_type and cpu_type != "host": + vm_config["cpu"] = cpu_type + if cpu_flags: + if "cpu" in vm_config: + vm_config["cpu"] = f"{vm_config['cpu']},{cpu_flags}" + else: + vm_config["cpu"] = f"host,{cpu_flags}" + if numa_enabled: + vm_config["numa"] = 1 + + # Hardware options + if bios_type == "ovmf": + # UEFI - requires OVMF firmware + vm_config["bios"] = "ovmf" + # Note: May need to add efidisk0 for UEFI variables storage + if machine_type and machine_type != "pc": + vm_config["machine"] = machine_type + if vga_type and vga_type != "std": + vm_config["vga"] = vga_type + if boot_order and boot_order != "cdn": + vm_config["boot"] = f"order={boot_order}" + + # Additional network interfaces + if network_interfaces: + for idx, nic in enumerate(network_interfaces, start=1): + bridge = nic.get('bridge', 'vmbr0') + model = nic.get('model', 'virtio') + vm_config[f"net{idx}"] = f"{model},bridge={bridge}" + + # ISO + if iso: + vm_config["ide2"] = f"{iso},media=cdrom" + + logger.info(f"Creating VM with config: {vm_config}") + result = self.proxmox.nodes(node_name).qemu.post(**vm_config) + task_id = result # UPID format: UPID:node:pid:timestamp:type:id:user: + logger.info(f"Proxmox API response (task ID): {task_id}") + + # VM creation is async - wait for the task to complete + import time + max_wait = 30 # Wait up to 30 seconds + wait_interval = 2 + total_waited = 0 + + logger.info(f"Waiting for VM {vmid} creation task to complete...") + while total_waited < max_wait: + time.sleep(wait_interval) + total_waited += wait_interval + + # Check task status + try: + task_status = self.proxmox.nodes(node_name).tasks(task_id).status.get() + task_state = task_status.get('status') + logger.info(f"Task status: {task_state}") + + if task_state == 'stopped': + # Task completed - check exit status + exit_status = task_status.get('exitstatus') + if exit_status == 'OK': + logger.info(f"VM {vmid} creation task completed successfully") + # Verify VM exists + try: + vm_status = self.proxmox.nodes(node_name).qemu(vmid).status.current.get() + logger.info(f"VM {vmid} verified on node {node_name}, status: {vm_status.get('status')}") + return True + except Exception as e: + logger.error(f"VM {vmid} task succeeded but VM doesn't exist: {e}") + return False + else: + # Task failed - get error log + try: + log = self.proxmox.nodes(node_name).tasks(task_id).log.get() + error_lines = [line.get('t', '') for line in log[-10:]] # Last 10 lines + logger.error(f"VM {vmid} creation failed. Task log:\n" + "\n".join(error_lines)) + except: + logger.error(f"VM {vmid} creation failed with exit status: {exit_status}") + return False + elif task_state == 'running': + logger.debug(f"Task still running, waited {total_waited}s...") + continue + except Exception as e: + logger.warning(f"Could not check task status: {e}") + # Fall back to checking if VM exists + try: + vm_status = self.proxmox.nodes(node_name).qemu(vmid).status.current.get() + logger.info(f"VM {vmid} exists despite task check failure, status: {vm_status.get('status')}") + return True + except: + continue + + # If we get here, VM wasn't created within timeout + logger.error(f"VM {vmid} was not created within {max_wait} seconds. Task may have failed.") + logger.error(f"Check Proxmox task log for UPID: {task_id}") + return False + + except ResourceException as e: + logger.error(f"Failed to create VM: {e}") + return False + except Exception as e: + logger.error(f"Unexpected error creating VM: {e}") + return False + + def create_vm_from_cloud_image( + self, + node_name: str, + vmid: int, + name: str, + sockets: int, + cores: int, + memory: int, + disk_size: int, + storage: str, + cloud_image_path: str, + network_bridge: str = "vmbr0", + progress_callback=None, + ) -> bool: + """Create a VM from a cloud image by uploading and importing the disk""" + try: + import time + import os + import requests + from app.core.security import decrypt_data + + logger.info(f"Creating VM {vmid} from cloud image {os.path.basename(cloud_image_path)}") + + if progress_callback: + progress_callback(0, "Creating VM configuration...") + + # Step 1: Create VM shell without disk + vm_config = { + "vmid": vmid, + "name": name, + "sockets": sockets, + "cores": cores, + "memory": memory, + "net0": f"virtio,bridge={network_bridge}", + "scsihw": "virtio-scsi-pci", + "ostype": "l26", + "agent": 1, + } + + logger.info(f"Creating VM shell: {vm_config}") + self.proxmox.nodes(node_name).qemu.post(**vm_config) + time.sleep(2) + + if progress_callback: + progress_callback(20, "VM created, uploading cloud image...") + + # Step 2: Upload cloud image to Proxmox using direct requests + # We'll upload it as an ISO first, then use qm importdisk + image_filename = os.path.basename(cloud_image_path) + file_size = os.path.getsize(cloud_image_path) + + url = f"https://{self.host.hostname}:{self.host.port}/api2/json/nodes/{node_name}/storage/local/upload" + + # Prepare auth + if self.host.api_token_id and self.host.api_token_secret: + try: + token_secret = decrypt_data(self.host.api_token_secret) + except Exception: + token_secret = self.host.api_token_secret + + if '!' in self.host.api_token_id: + full_token_id = self.host.api_token_id + else: + full_token_id = f"{self.host.username}!{self.host.api_token_id}" + + headers = {'Authorization': f'PVEAPIToken={full_token_id}={token_secret}'} + auth = None + else: + try: + password = decrypt_data(self.host.password) + except Exception: + password = self.host.password + headers = {} + auth = (self.host.username, password) + + logger.info(f"Uploading {file_size/(1024*1024):.1f} MB cloud image to {node_name}:local") + + # Upload file + with open(cloud_image_path, 'rb') as img_file: + files = {'filename': (image_filename, img_file, 'application/octet-stream')} + data = {'content': 'iso'} # Upload to ISO storage temporarily + + response = requests.post( + url, + auth=auth, + headers=headers, + files=files, + data=data, + verify=self.host.verify_ssl, + timeout=1800 # 30 min for large images + ) + + if response.status_code != 200: + raise Exception(f"Upload failed: {response.text}") + + if progress_callback: + progress_callback(60, "Cloud image uploaded, importing disk...") + + # Step 3: Use qm importdisk to import the cloud image as VM disk + # This needs to be done via command execution on the Proxmox node + # For now, we'll create a simplified VM and note that manual import is needed + + # Add a small placeholder disk that can be replaced + self.proxmox.nodes(node_name).qemu(vmid).config.put( + scsi0=f"{storage}:{disk_size}", + boot="order=scsi0" + ) + + # Add cloud-init drive + self.proxmox.nodes(node_name).qemu(vmid).config.put( + ide2=f"{storage}:cloudinit" + ) + + if progress_callback: + progress_callback(80, "Configuring VM...") + + logger.info(f"VM {vmid} created. Cloud image uploaded to local:iso/{image_filename}") + logger.info(f"Note: Manual import required: qm importdisk {vmid} /var/lib/vz/template/iso/{image_filename} {storage}") + + if progress_callback: + progress_callback(100, "VM created with cloud image") + + return True + + except Exception as e: + logger.error(f"Failed to create VM from cloud image: {e}") + return False + + def configure_cloud_init( + self, + node_name: str, + vmid: int, + user: str, + password: Optional[str] = None, + ssh_keys: Optional[str] = None, + ip_config: Optional[str] = None, + nameserver: Optional[str] = None, + ) -> bool: + """Configure cloud-init for a VM""" + try: + config = { + "ciuser": user, + "searchdomain": "local", + } + + if password: + config["cipassword"] = password + + if ssh_keys: + config["sshkeys"] = ssh_keys + + if ip_config: + config["ipconfig0"] = ip_config + else: + config["ipconfig0"] = "ip=dhcp" + + if nameserver: + config["nameserver"] = nameserver + + self.proxmox.nodes(node_name).qemu(vmid).config.put(**config) + logger.info(f"Configured cloud-init for VM {vmid}") + return True + except ResourceException as e: + logger.error(f"Failed to configure cloud-init: {e}") + return False + + def start_vm(self, node_name: str, vmid: int) -> bool: + """Start a VM""" + try: + self.proxmox.nodes(node_name).qemu(vmid).status.start.post() + logger.info(f"Started VM {vmid}") + return True + except ResourceException as e: + logger.error(f"Failed to start VM: {e}") + return False + + def stop_vm(self, node_name: str, vmid: int) -> bool: + """Stop a VM (graceful shutdown)""" + try: + self.proxmox.nodes(node_name).qemu(vmid).status.shutdown.post() + logger.info(f"Stopped VM {vmid}") + return True + except ResourceException as e: + logger.error(f"Failed to stop VM: {e}") + return False + + def shutdown_vm(self, node_name: str, vmid: int) -> bool: + """Shutdown a VM (force power off)""" + try: + self.proxmox.nodes(node_name).qemu(vmid).status.stop.post() + logger.info(f"Powered off VM {vmid}") + return True + except ResourceException as e: + logger.error(f"Failed to power off VM: {e}") + return False + + def restart_vm(self, node_name: str, vmid: int) -> bool: + """Restart a VM""" + try: + self.proxmox.nodes(node_name).qemu(vmid).status.reboot.post() + logger.info(f"Restarted VM {vmid}") + return True + except ResourceException as e: + logger.error(f"Failed to restart VM: {e}") + return False + + def delete_vm(self, node_name: str, vmid: int) -> bool: + """Delete a VM""" + try: + self.proxmox.nodes(node_name).qemu(vmid).delete() + logger.info(f"Deleted VM {vmid}") + return True + except ResourceException as e: + logger.error(f"Failed to delete VM: {e}") + return False + + def get_vm_status(self, node_name: str, vmid: int) -> Optional[Dict[str, Any]]: + """Get VM status""" + try: + status = self.proxmox.nodes(node_name).qemu(vmid).status.current.get() + return status + except ResourceException as e: + logger.error(f"Failed to get VM status: {e}") + return None + + def get_vm_config(self, node_name: str, vmid: int) -> Optional[Dict[str, Any]]: + """Get VM configuration""" + try: + config = self.proxmox.nodes(node_name).qemu(vmid).config.get() + return config + except ResourceException as e: + logger.error(f"Failed to get VM config: {e}") + return None + + def get_all_vms(self) -> List[Dict[str, Any]]: + """Get all VMs across all nodes in the cluster""" + all_vms = [] + try: + nodes = self.get_nodes() + for node_data in nodes: + node_name = node_data.get('node') + try: + vms = self.proxmox.nodes(node_name).qemu.get() + for vm in vms: + all_vms.append({ + 'vmid': vm.get('vmid'), + 'name': vm.get('name'), + 'status': vm.get('status'), + 'node': node_name, + 'cpus': vm.get('cpus', 0), + 'maxmem': vm.get('maxmem', 0), + 'maxdisk': vm.get('maxdisk', 0), + }) + except Exception as e: + logger.error(f"Failed to get VMs from node {node_name}: {e}") + return all_vms + except Exception as e: + logger.error(f"Failed to get all VMs: {e}") + return [] + + def execute_qemu_agent_command( + self, node_name: str, vmid: int, command: str + ) -> Optional[Dict[str, Any]]: + """Execute command via QEMU guest agent""" + try: + result = self.proxmox.nodes(node_name).qemu(vmid).agent.exec.post( + command=command + ) + return result + except ResourceException as e: + logger.error(f"Failed to execute QEMU agent command: {e}") + return None + + +def poll_proxmox_resources(db: Session, host_id: int) -> bool: + """Poll Proxmox host for current resource status""" + try: + host = db.query(ProxmoxHost).filter(ProxmoxHost.id == host_id).first() + if not host or not host.is_active: + return False + + service = ProxmoxService(host) + + if not service.test_connection(): + logger.error(f"Cannot connect to Proxmox host {host.name}") + return False + + # Get nodes and update database + nodes = service.get_nodes() + for node_data in nodes: + node_name = node_data.get("node") + resources = service.get_node_resources(node_name) + + if not resources: + continue + + # Update or create node record + node = ( + db.query(ProxmoxNode) + .filter( + ProxmoxNode.host_id == host_id, + ProxmoxNode.node_name == node_name, + ) + .first() + ) + + if node: + node.status = resources["status"] + node.cpu_cores = resources["cpu_cores"] + node.cpu_usage = resources["cpu_usage"] + node.memory_total = resources["memory_total"] + node.memory_used = resources["memory_used"] + node.disk_total = resources["disk_total"] + node.disk_used = resources["disk_used"] + node.uptime = resources["uptime"] + node.last_updated = datetime.utcnow() + else: + node = ProxmoxNode( + host_id=host_id, + node_name=node_name, + status=resources["status"], + cpu_cores=resources["cpu_cores"], + cpu_usage=resources["cpu_usage"], + memory_total=resources["memory_total"], + memory_used=resources["memory_used"], + disk_total=resources["disk_total"], + disk_used=resources["disk_used"], + uptime=resources["uptime"], + last_updated=datetime.utcnow(), + ) + db.add(node) + + host.last_poll = datetime.utcnow() + db.commit() + logger.info(f"Successfully polled Proxmox host {host.name}") + return True + + except Exception as e: + logger.error(f"Error polling Proxmox resources: {e}") + db.rollback() + return False diff --git a/src/backend/app/services/updates.py b/src/backend/app/services/updates.py new file mode 100644 index 0000000..5b6be2d --- /dev/null +++ b/src/backend/app/services/updates.py @@ -0,0 +1,275 @@ +"""VM update management service""" +from typing import Optional, Dict, Any +from sqlalchemy.orm import Session +from app.models import VirtualMachine, UpdateLog, OSType +from app.services.proxmox import ProxmoxService +from app.models import ProxmoxHost, ProxmoxNode +import paramiko +import logging +from datetime import datetime + +logger = logging.getLogger(__name__) + + +class UpdateService: + """Service for managing VM updates""" + + def __init__(self, db: Session): + self.db = db + + def _get_ssh_client(self, vm: VirtualMachine) -> Optional[paramiko.SSHClient]: + """Create SSH connection to VM""" + try: + client = paramiko.SSHClient() + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + + if vm.ssh_key: + # Use SSH key authentication + from io import StringIO + key_file = StringIO(vm.ssh_key) + pkey = paramiko.RSAKey.from_private_key(key_file) + client.connect( + hostname=vm.ip_address, + username=vm.username, + pkey=pkey, + timeout=30, + ) + else: + # Use password authentication + client.connect( + hostname=vm.ip_address, + username=vm.username, + password=vm.password, + timeout=30, + ) + + return client + + except Exception as e: + logger.error(f"Failed to connect to VM via SSH: {e}") + return None + + def _get_update_commands(self, os_type: OSType) -> Dict[str, str]: + """Get update commands for different OS types""" + commands = { + OSType.UBUNTU: { + "update": "sudo apt-get update", + "upgrade": "sudo DEBIAN_FRONTEND=noninteractive apt-get upgrade -y", + "check": "apt list --upgradable", + }, + OSType.DEBIAN: { + "update": "sudo apt-get update", + "upgrade": "sudo DEBIAN_FRONTEND=noninteractive apt-get upgrade -y", + "check": "apt list --upgradable", + }, + OSType.CENTOS: { + "update": "sudo yum check-update", + "upgrade": "sudo yum update -y", + "check": "yum list updates", + }, + OSType.ROCKY: { + "update": "sudo dnf check-update", + "upgrade": "sudo dnf update -y", + "check": "dnf list updates", + }, + OSType.ALMA: { + "update": "sudo dnf check-update", + "upgrade": "sudo dnf update -y", + "check": "dnf list updates", + }, + } + + return commands.get(os_type, commands[OSType.UBUNTU]) + + def check_updates(self, vm_id: int) -> Optional[Dict[str, Any]]: + """Check for available updates""" + try: + vm = self.db.query(VirtualMachine).filter(VirtualMachine.id == vm_id).first() + if not vm: + logger.error(f"VM {vm_id} not found") + return None + + if not vm.ip_address: + logger.error(f"VM {vm_id} has no IP address") + return None + + client = self._get_ssh_client(vm) + if not client: + return None + + commands = self._get_update_commands(vm.os_type) + + # Update package lists + stdin, stdout, stderr = client.exec_command(commands["update"]) + stdout.channel.recv_exit_status() # Wait for command to complete + + # Check for updates + stdin, stdout, stderr = client.exec_command(commands["check"]) + output = stdout.read().decode() + exit_status = stdout.channel.recv_exit_status() + + client.close() + + # Parse output to count available updates + lines = output.strip().split("\n") + update_count = len([line for line in lines if line.strip()]) + + return { + "updates_available": update_count, + "output": output, + } + + except Exception as e: + logger.error(f"Failed to check updates: {e}") + return None + + def install_updates(self, vm_id: int, user_id: int) -> Optional[int]: + """ + Install updates on a VM + + Args: + vm_id: Database ID of the VM + user_id: User initiating the update + + Returns: + Update log ID if successful, None otherwise + """ + try: + vm = self.db.query(VirtualMachine).filter(VirtualMachine.id == vm_id).first() + if not vm: + logger.error(f"VM {vm_id} not found") + return None + + if not vm.ip_address: + logger.error(f"VM {vm_id} has no IP address") + return None + + # Create update log + update_log = UpdateLog( + vm_id=vm_id, + initiated_by=user_id, + status="running", + started_at=datetime.utcnow(), + ) + self.db.add(update_log) + self.db.commit() + + try: + client = self._get_ssh_client(vm) + if not client: + raise Exception("Failed to connect via SSH") + + commands = self._get_update_commands(vm.os_type) + + # Update package lists + logger.info(f"Updating package lists on VM {vm_id}") + stdin, stdout, stderr = client.exec_command(commands["update"]) + stdout.channel.recv_exit_status() + + # Install updates + logger.info(f"Installing updates on VM {vm_id}") + stdin, stdout, stderr = client.exec_command(commands["upgrade"]) + output = stdout.read().decode() + error_output = stderr.read().decode() + exit_status = stdout.channel.recv_exit_status() + + client.close() + + if exit_status != 0: + raise Exception(f"Update failed with exit code {exit_status}") + + # Update log + update_log.status = "completed" + update_log.output = output + update_log.completed_at = datetime.utcnow() + + # Try to count packages updated (rough estimate) + if "ubuntu" in vm.os_type.value or "debian" in vm.os_type.value: + packages_updated = output.count("Setting up") + else: + packages_updated = output.count("Installed") + output.count("Updated") + + update_log.packages_updated = packages_updated + self.db.commit() + + logger.info(f"Successfully installed updates on VM {vm_id}") + return update_log.id + + except Exception as e: + logger.error(f"Failed to install updates: {e}") + update_log.status = "failed" + update_log.error_message = str(e) + update_log.completed_at = datetime.utcnow() + self.db.commit() + return None + + except Exception as e: + logger.error(f"Failed to create update log: {e}") + return None + + def get_update_history(self, vm_id: int) -> list: + """Get update history for a VM""" + try: + logs = ( + self.db.query(UpdateLog) + .filter(UpdateLog.vm_id == vm_id) + .order_by(UpdateLog.started_at.desc()) + .all() + ) + return logs + except Exception as e: + logger.error(f"Failed to get update history: {e}") + return [] + + def install_qemu_agent(self, vm_id: int) -> bool: + """Install QEMU guest agent on a VM if not already installed""" + try: + vm = self.db.query(VirtualMachine).filter(VirtualMachine.id == vm_id).first() + if not vm or not vm.ip_address: + return False + + client = self._get_ssh_client(vm) + if not client: + return False + + # Determine package name based on OS + if vm.os_type in [OSType.UBUNTU, OSType.DEBIAN]: + install_cmd = "sudo apt-get install -y qemu-guest-agent" + start_cmd = "sudo systemctl start qemu-guest-agent" + enable_cmd = "sudo systemctl enable qemu-guest-agent" + elif vm.os_type in [OSType.CENTOS]: + install_cmd = "sudo yum install -y qemu-guest-agent" + start_cmd = "sudo systemctl start qemu-guest-agent" + enable_cmd = "sudo systemctl enable qemu-guest-agent" + elif vm.os_type in [OSType.ROCKY, OSType.ALMA]: + install_cmd = "sudo dnf install -y qemu-guest-agent" + start_cmd = "sudo systemctl start qemu-guest-agent" + enable_cmd = "sudo systemctl enable qemu-guest-agent" + else: + logger.error(f"Unsupported OS type for QEMU agent installation: {vm.os_type}") + return False + + # Install + stdin, stdout, stderr = client.exec_command(install_cmd) + exit_status = stdout.channel.recv_exit_status() + + if exit_status != 0: + logger.error(f"Failed to install QEMU agent: {stderr.read().decode()}") + return False + + # Start + stdin, stdout, stderr = client.exec_command(start_cmd) + stdout.channel.recv_exit_status() + + # Enable on boot + stdin, stdout, stderr = client.exec_command(enable_cmd) + stdout.channel.recv_exit_status() + + client.close() + + logger.info(f"Successfully installed QEMU guest agent on VM {vm_id}") + return True + + except Exception as e: + logger.error(f"Failed to install QEMU agent: {e}") + return False diff --git a/src/backend/create_admin.py b/src/backend/create_admin.py new file mode 100644 index 0000000..c11dae7 --- /dev/null +++ b/src/backend/create_admin.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +"""Create default admin user if it doesn't exist""" +import sys +import os +sys.path.insert(0, '/opt/depl0y/backend') + +# Set DATABASE_URL environment variable to ensure SQLite is used +os.environ['DATABASE_URL'] = 'sqlite:////var/lib/depl0y/db/depl0y.db' + +try: + from app.core.database import SessionLocal + from app.models import User, UserRole + from app.core.security import get_password_hash + + print("✓ Imports successful") + + def create_admin_user(): + """Create default admin user with 2FA disabled""" + db = SessionLocal() + try: + print("✓ Database connection established") + + # Check if admin user already exists + existing_admin = db.query(User).filter(User.username == 'admin').first() + if existing_admin: + print(f"⚠️ Admin user already exists (ID: {existing_admin.id})") + print(f" Current 2FA status: {existing_admin.totp_enabled}") + # Reset password to default and ensure 2FA is disabled + existing_admin.hashed_password = get_password_hash("admin") + existing_admin.totp_enabled = False + existing_admin.totp_secret = None + existing_admin.is_active = True + db.commit() + print("✓ Admin password reset to: admin") + print("✓ 2FA explicitly disabled (totp_enabled=False)") + print("✓ 2FA secret cleared (totp_secret=None)") + print("✓ Account activated") + + # Verify the change + db.refresh(existing_admin) + print(f"✓ VERIFIED: totp_enabled = {existing_admin.totp_enabled}") + return + + # Create new admin user + print("Creating new admin user...") + hashed_password = get_password_hash("admin") + admin_user = User( + username="admin", + email="admin@localhost", + hashed_password=hashed_password, + role=UserRole.ADMIN, + is_active=True, + totp_enabled=False, # Explicitly disable 2FA + totp_secret=None + ) + + db.add(admin_user) + db.commit() + db.refresh(admin_user) + + print("✓ Created default admin user") + print(f" ID: {admin_user.id}") + print(f" Username: admin") + print(f" Password: admin") + print(f" 2FA Enabled: {admin_user.totp_enabled}") + print(f" 2FA Secret: {admin_user.totp_secret}") + print(f" Active: {admin_user.is_active}") + print(f" Role: {admin_user.role}") + + except Exception as e: + print(f"✗ Error with admin user: {e}") + import traceback + traceback.print_exc() + db.rollback() + finally: + db.close() + + create_admin_user() + +except Exception as e: + print(f"✗ Fatal error during import/setup: {e}") + import traceback + traceback.print_exc() + sys.exit(1) diff --git a/src/backend/requirements.txt b/src/backend/requirements.txt new file mode 100644 index 0000000..301b5b1 --- /dev/null +++ b/src/backend/requirements.txt @@ -0,0 +1,48 @@ +# FastAPI and web framework +fastapi==0.104.1 +uvicorn[standard]==0.24.0 +python-multipart==0.0.6 + +# Database +sqlalchemy==2.0.23 +pymysql==1.1.0 +cryptography==41.0.7 +alembic==1.13.0 + +# Authentication and security +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +pyotp==2.9.0 +qrcode==7.4.2 +pillow==10.1.0 + +# Proxmox +proxmoxer==2.0.1 +requests==2.31.0 + +# SSH and remote management +paramiko==3.4.0 + +# Configuration and environment +pydantic==2.5.0 +pydantic-settings==2.1.0 +python-dotenv==1.0.0 + +# YAML processing for cloud-init +pyyaml==6.0.1 + +# Logging +python-json-logger==2.0.7 + +# File handling +aiofiles==23.2.1 + +# Validation +email-validator==2.1.0 + +# Testing (optional) +pytest==7.4.3 +pytest-asyncio==0.21.1 +httpx==0.25.2 +weasyprint==66.0 +markdown==3.10 diff --git a/src/frontend/dist/assets/BugReport-Ct4FqCP3.css b/src/frontend/dist/assets/BugReport-Ct4FqCP3.css new file mode 100644 index 0000000..c5f29a7 --- /dev/null +++ b/src/frontend/dist/assets/BugReport-Ct4FqCP3.css @@ -0,0 +1 @@ +.bug-report-page[data-v-1456d47b]{max-width:1200px;margin:0 auto}.page-header[data-v-1456d47b]{margin-bottom:2rem}.page-header h1[data-v-1456d47b]{margin:0 0 .5rem;font-size:2rem;font-weight:700;color:var(--text-primary)}.subtitle[data-v-1456d47b]{margin:0;font-size:1rem;color:var(--text-secondary)}.card[data-v-1456d47b]{background:#fff;border-radius:.5rem;box-shadow:var(--shadow-md);padding:2rem}.bug-report-form[data-v-1456d47b]{display:flex;flex-direction:column;gap:2rem}.form-section[data-v-1456d47b]{display:flex;flex-direction:column;gap:1.5rem}.form-section h3[data-v-1456d47b]{margin:0;font-size:1.25rem;font-weight:600;color:var(--text-primary);padding-bottom:.75rem;border-bottom:2px solid var(--border-color)}.form-group[data-v-1456d47b]{display:flex;flex-direction:column;gap:.5rem}.form-label[data-v-1456d47b]{font-size:.875rem;font-weight:600;color:var(--text-primary)}.form-label.required[data-v-1456d47b]:after{content:" *";color:#ef4444}.form-control[data-v-1456d47b]{width:100%;padding:.75rem;border:1px solid var(--border-color);border-radius:.375rem;font-size:.875rem;transition:all .2s}.form-control[data-v-1456d47b]:focus{outline:none;border-color:#3b82f6;box-shadow:0 0 0 3px #3b82f61a}.code-input[data-v-1456d47b]{font-family:Consolas,Monaco,Courier New,monospace;font-size:.8rem;background-color:#f9fafb}.help-text[data-v-1456d47b]{margin:0;font-size:.75rem;color:var(--text-secondary)}.info-grid[data-v-1456d47b]{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:1rem}.info-item[data-v-1456d47b]{display:flex;flex-direction:column;gap:.5rem;padding:1rem;background-color:#f9fafb;border-radius:.375rem;border:1px solid var(--border-color)}.info-label[data-v-1456d47b]{font-size:.75rem;font-weight:600;text-transform:uppercase;letter-spacing:.05em;color:var(--text-secondary)}.info-value[data-v-1456d47b]{font-family:Consolas,Monaco,Courier New,monospace;font-size:.8rem;color:var(--text-primary);word-break:break-all;background-color:#fff;padding:.5rem;border-radius:.25rem;border:1px solid var(--border-color)}.form-actions[data-v-1456d47b]{display:flex;justify-content:flex-end;gap:1rem;padding-top:1rem;border-top:1px solid var(--border-color)}.btn[data-v-1456d47b]{padding:.75rem 1.5rem;border-radius:.375rem;font-size:.875rem;font-weight:600;cursor:pointer;transition:all .2s;border:none}.btn-secondary[data-v-1456d47b]{background-color:#fff;color:var(--text-primary);border:1px solid var(--border-color)}.btn-secondary[data-v-1456d47b]:hover{background-color:#f9fafb}.btn-primary[data-v-1456d47b]{background-color:#ef4444;color:#fff}.btn-primary[data-v-1456d47b]:hover{background-color:#dc2626}.btn-primary[data-v-1456d47b]:disabled{opacity:.5;cursor:not-allowed}@media (max-width: 768px){.card[data-v-1456d47b]{padding:1.5rem}.form-actions[data-v-1456d47b]{flex-direction:column}.btn[data-v-1456d47b]{width:100%}} diff --git a/src/frontend/dist/assets/BugReport-DLW0NuSM.js b/src/frontend/dist/assets/BugReport-DLW0NuSM.js new file mode 100644 index 0000000..de744cb --- /dev/null +++ b/src/frontend/dist/assets/BugReport-DLW0NuSM.js @@ -0,0 +1 @@ +import{_ as b,c as f,o as c,a as o,w as m,e as i,v as n,t as u,u as g,x as v,r as d,j as _,k as w,l as y}from"./index-DpP9cWht.js";const D={name:"BugReport",setup(){const l=g(),e=v(),t=d(!1),s=d({subject:"",description:"",error_details:"",page_url:"",browser_info:""}),a=_(()=>navigator.userAgent);return w(()=>{s.value.page_url=document.referrer||window.location.href,s.value.browser_info=a.value}),{formData:s,browserInfo:a,submitting:t,submitReport:async()=>{t.value=!0;try{await y.bugReport.submit({subject:s.value.subject,description:s.value.description,error_details:s.value.error_details||null,page_url:s.value.page_url,browser_info:s.value.browser_info}),e.success("Bug report submitted successfully! Thank you for your feedback."),s.value={subject:"",description:"",error_details:"",page_url:document.referrer||window.location.href,browser_info:a.value},setTimeout(()=>{l.go(-1)},1500)}catch(r){console.error("Failed to submit bug report:",r),e.error("Failed to submit bug report. Please try again later.")}finally{t.value=!1}}}}},x={class:"bug-report-page"},B={class:"card"},R={class:"form-section"},h={class:"form-group"},j={class:"form-group"},k={class:"form-group"},I={class:"form-section"},S={class:"info-grid"},q={class:"info-item"},C={class:"info-value"},P={class:"info-item"},T={class:"info-value"},U={class:"form-actions"},V=["disabled"];function F(l,e,t,s,a,p){return c(),f("div",x,[e[14]||(e[14]=o("div",{class:"page-header"},[o("h1",null,"Report a Bug"),o("p",{class:"subtitle"},"Help us improve Depl0y by reporting issues you encounter")],-1)),o("div",B,[o("form",{onSubmit:e[4]||(e[4]=m((...r)=>s.submitReport&&s.submitReport(...r),["prevent"])),class:"bug-report-form"},[o("div",R,[e[10]||(e[10]=o("h3",null,"Bug Information",-1)),o("div",h,[e[5]||(e[5]=o("label",{class:"form-label required"},"Subject",-1)),i(o("input",{"onUpdate:modelValue":e[0]||(e[0]=r=>s.formData.subject=r),type:"text",required:"",class:"form-control",placeholder:"Brief description of the issue"},null,512),[[n,s.formData.subject]])]),o("div",j,[e[6]||(e[6]=o("label",{class:"form-label required"},"Description",-1)),i(o("textarea",{"onUpdate:modelValue":e[1]||(e[1]=r=>s.formData.description=r),required:"",rows:"8",class:"form-control",placeholder:"Detailed description of what happened, steps to reproduce, expected vs actual behavior..."},null,512),[[n,s.formData.description]]),e[7]||(e[7]=o("p",{class:"help-text"},"Please be as detailed as possible to help us understand and fix the issue",-1))]),o("div",k,[e[8]||(e[8]=o("label",{class:"form-label"},"Error Details (optional)",-1)),i(o("textarea",{"onUpdate:modelValue":e[2]||(e[2]=r=>s.formData.error_details=r),rows:"6",class:"form-control code-input",placeholder:"Paste any error messages, console logs, or stack traces here..."},null,512),[[n,s.formData.error_details]]),e[9]||(e[9]=o("p",{class:"help-text"},"Check your browser console (F12) for any error messages",-1))])]),o("div",I,[e[13]||(e[13]=o("h3",null,"Automatically Captured Information",-1)),o("div",S,[o("div",q,[e[11]||(e[11]=o("label",{class:"info-label"},"Page URL",-1)),o("code",C,u(s.formData.page_url),1)]),o("div",P,[e[12]||(e[12]=o("label",{class:"info-label"},"Browser",-1)),o("code",T,u(s.browserInfo),1)])])]),o("div",U,[o("button",{type:"button",onClick:e[3]||(e[3]=r=>l.$router.go(-1)),class:"btn btn-secondary"}," Cancel "),o("button",{type:"submit",disabled:s.submitting,class:"btn btn-primary"},u(s.submitting?"Submitting...":"Submit Bug Report"),9,V)])],32)])])}const A=b(D,[["render",F],["__scopeId","data-v-1456d47b"]]);export{A as default}; diff --git a/src/frontend/dist/assets/CloudImages-7wEL8gnb.css b/src/frontend/dist/assets/CloudImages-7wEL8gnb.css new file mode 100644 index 0000000..fa1c4a1 --- /dev/null +++ b/src/frontend/dist/assets/CloudImages-7wEL8gnb.css @@ -0,0 +1 @@ +.download-progress[data-v-f1a8c3e0]{display:flex;flex-direction:column;gap:.25rem}.progress-bar-container[data-v-f1a8c3e0]{width:100px;height:8px;background-color:var(--background);border-radius:4px;overflow:hidden}.progress-bar-fill[data-v-f1a8c3e0]{height:100%;background-color:var(--primary);transition:width .3s ease}.modal[data-v-f1a8c3e0]{position:fixed;top:0;left:0;width:100%;height:100%;background-color:#00000080;display:flex;align-items:center;justify-content:center;z-index:1000}.modal-content[data-v-f1a8c3e0]{background:#1e293b;border:2px solid #475569;border-radius:12px;width:90%;max-width:600px;max-height:90vh;overflow-y:auto;box-shadow:0 25px 50px -12px #000000b3}.modal-header[data-v-f1a8c3e0]{display:flex;justify-content:space-between;align-items:center;padding:1.5rem;border-bottom:2px solid #475569;background:#334155}.modal-header h3[data-v-f1a8c3e0]{margin:0;color:#fff;font-weight:600}.btn-close[data-v-f1a8c3e0]{background:#475569;border:none;font-size:1.5rem;line-height:1;cursor:pointer;color:#cbd5e1;padding:0;width:2rem;height:2rem;border-radius:6px;transition:all .2s}.btn-close[data-v-f1a8c3e0]:hover{background:#ef4444;color:#fff}.modal-body[data-v-f1a8c3e0]{padding:1.5rem;background:#1e293b}.modal-body[data-v-f1a8c3e0] .form-label{color:#e2e8f0;font-weight:500}.modal-body[data-v-f1a8c3e0] .form-control,.modal-body[data-v-f1a8c3e0] input,.modal-body[data-v-f1a8c3e0] select,.modal-body[data-v-f1a8c3e0] textarea{background:#0f172a;border:1px solid #475569;color:#f1f5f9}.modal-body[data-v-f1a8c3e0] .form-control:focus,.modal-body[data-v-f1a8c3e0] input:focus,.modal-body[data-v-f1a8c3e0] select:focus,.modal-body[data-v-f1a8c3e0] textarea:focus{border-color:#3b82f6;outline:none;box-shadow:0 0 0 2px #3b82f633}.modal-body[data-v-f1a8c3e0] .text-muted{color:#94a3b8!important}.modal-footer[data-v-f1a8c3e0]{display:flex;justify-content:flex-end;gap:.5rem;padding:1rem 1.5rem;margin-top:0;border-top:2px solid #475569;background:#334155}.checkbox-list[data-v-f1a8c3e0]{display:flex;flex-direction:column;gap:.5rem;max-height:300px;overflow-y:auto;padding:.75rem;border:2px solid #475569;border-radius:8px;background-color:#0f172a}.checkbox-item[data-v-f1a8c3e0]{display:flex;align-items:center;gap:.75rem;cursor:pointer;padding:.75rem;border-radius:6px;background:#1e293b;border:1px solid #334155;transition:all .2s}.checkbox-item[data-v-f1a8c3e0]:hover{background:#334155;border-color:#3b82f6}.checkbox-item span[data-v-f1a8c3e0]{color:#f1f5f9;font-weight:500}.checkbox-item input[type=checkbox][data-v-f1a8c3e0]{cursor:pointer;width:18px;height:18px;accent-color:#3b82f6}.alert[data-v-f1a8c3e0]{padding:1rem;border-radius:8px;margin-top:1rem;border:2px solid}.alert-info[data-v-f1a8c3e0]{background-color:#3b82f626;border-color:#3b82f6;color:#dbeafe}.alert-info p[data-v-f1a8c3e0]{color:#dbeafe;margin:.25rem 0}.template-status-section[data-v-f1a8c3e0]{padding:1.5rem;background:#ffffff05;border-bottom:2px solid #475569}.template-status-title[data-v-f1a8c3e0]{color:#f1f5f9;margin-bottom:1rem;font-size:1.25rem;font-weight:600}.template-status-grid[data-v-f1a8c3e0]{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:1rem}.node-status-card[data-v-f1a8c3e0]{background:#1e293b;border:2px solid #475569;border-radius:8px;padding:1rem}.node-status-header[data-v-f1a8c3e0]{display:flex;justify-content:space-between;align-items:center;margin-bottom:.75rem;padding-bottom:.75rem;border-bottom:1px solid #475569}.node-name[data-v-f1a8c3e0]{color:#f1f5f9;font-weight:600;font-size:1.1rem}.template-count[data-v-f1a8c3e0]{color:#94a3b8;font-size:.875rem}.template-list[data-v-f1a8c3e0]{display:flex;flex-direction:column;gap:.5rem}.template-item[data-v-f1a8c3e0]{display:flex;justify-content:space-between;align-items:center;padding:.5rem;background:#0f172a;border-radius:4px}.template-vmid[data-v-f1a8c3e0]{color:#e2e8f0;font-weight:500}.template-badge[data-v-f1a8c3e0]{padding:.25rem .75rem;border-radius:4px;font-size:.875rem;font-weight:500}.template-badge.exists[data-v-f1a8c3e0]{background:#22c55e33;color:#4ade80;border:1px solid #22c55e}.template-badge.missing[data-v-f1a8c3e0]{background:#ef444433;color:#f87171;border:1px solid #ef4444} diff --git a/src/frontend/dist/assets/CloudImages-P8bBuEe-.js b/src/frontend/dist/assets/CloudImages-P8bBuEe-.js new file mode 100644 index 0000000..ea444cd --- /dev/null +++ b/src/frontend/dist/assets/CloudImages-P8bBuEe-.js @@ -0,0 +1 @@ +import{_ as W,c as a,o as n,a as e,d as k,t as d,F as I,p as x,m as X,n as Y,e as f,A as N,E as Z,w as D,v as F,B as $,x as ee,r as c,k as te,G as oe,l as u}from"./index-DpP9cWht.js";const se={name:"CloudImages",setup(){const r=ee(),t=c([]),T=c([]),s=c(!1),C=c(!1),S=c(!1),o=c(null),m=c(!1),b=c(!1),M=c({}),U=c(!1),A=c(!1);let v=null;const w=c({name:"",filename:"",download_url:"",os_type:"",version:"",architecture:"amd64",checksum:""}),h=c({selectedNodeId:"",selectedImageIds:[]}),y=async()=>{s.value=!0;try{const l=await u.cloudImages.list();t.value=l.data}catch(l){console.error("Failed to fetch cloud images:",l)}finally{s.value=!1}},B=async l=>{try{await u.cloudImages.download(l),r.success("Cloud image download started"),V()}catch(i){console.error("Failed to start download:",i),r.error("Failed to start download")}},O=async()=>{m.value=!0;try{o.value?(await u.cloudImages.update(o.value.id,w.value),r.success("Cloud image updated successfully")):(await u.cloudImages.create(w.value),r.success("Cloud image added successfully")),L(),y()}catch(l){console.error("Failed to save cloud image:",l),r.error("Failed to save cloud image")}finally{m.value=!1}},E=l=>{o.value=l,w.value={name:l.name,filename:l.filename,download_url:l.download_url,os_type:l.os_type,version:l.version||"",architecture:l.architecture,checksum:l.checksum||""},C.value=!0},R=async l=>{if(confirm("Are you sure you want to delete this cloud image?"))try{await u.cloudImages.delete(l),r.success("Cloud image deleted successfully"),y()}catch(i){console.error("Failed to delete cloud image:",i),r.error("Failed to delete cloud image")}},L=()=>{C.value=!1,o.value=null,w.value={name:"",filename:"",download_url:"",os_type:"",version:"",architecture:"amd64",checksum:""}},z=l=>{if(!l)return"0 B";const i=["B","KB","MB","GB","TB"],g=Math.floor(Math.log(l)/Math.log(1024));return Math.round(l/Math.pow(1024,g)*100)/100+" "+i[g]},P=l=>({ubuntu:"Ubuntu",debian:"Debian",centos:"CentOS",rocky:"Rocky Linux",alma:"AlmaLinux",fedora:"Fedora",opensuse:"openSUSE",other:"Other"})[l]||l,q=async()=>{try{const l=await u.proxmox.listHosts(),i=[];for(const g of l.data)try{const p=await u.proxmox.listNodes(g.id);p.data&&p.data.length>0&&p.data.forEach(_=>{i.push({id:_.id,node_name:_.node_name,proxmox_host_name:g.name})})}catch(p){console.error(`Failed to fetch nodes for host ${g.name}:`,p)}T.value=i}catch(l){console.error("Failed to fetch nodes:",l),r.error("Failed to fetch Proxmox nodes")}},H=async()=>{if(!h.value.selectedNodeId||h.value.selectedImageIds.length===0){r.error("Please select a node and at least one cloud image");return}b.value=!0;try{await u.cloudImages.setupTemplates({node_id:parseInt(h.value.selectedNodeId),cloud_image_ids:h.value.selectedImageIds}),r.success("Template setup started! This may take several minutes."),S.value=!1,h.value={selectedNodeId:"",selectedImageIds:[]}}catch(l){console.error("Failed to setup templates:",l),r.error("Failed to start template setup")}finally{b.value=!1}},j=async()=>{S.value=!0,await q()},V=()=>{v||(v=setInterval(()=>{y(),!t.value.some(i=>i.download_status==="downloading")&&v&&(clearInterval(v),v=null)},2e3))},G=async()=>{U.value=!0,M.value={};try{const l=await u.proxmox.listHosts(),i={};for(const g of l.data)try{const p=await u.proxmox.listNodes(g.id);if(p.data&&p.data.length>0)for(const _ of p.data){const Q=await u.cloudImages.checkTemplates(_.id);i[_.id]=Q.data}}catch(p){console.error(`Failed to check templates for host ${g.name}:`,p)}M.value=i,r.success("Template status checked successfully")}catch(l){console.error("Failed to check templates:",l),r.error("Failed to check template status")}finally{U.value=!1}},K=l=>Object.values(l).filter(i=>i).length,J=async()=>{A.value=!0;try{const l=await u.cloudImages.fetchLatest();l.data.updated_count>0?(r.success(`Updated ${l.data.updated_count} cloud image(s)`),await y()):r.info("All cloud images are up to date"),l.data.errors&&l.data.errors.length>0&&r.warning(`Some images could not be updated: ${l.data.errors.length} errors`)}catch(l){console.error("Failed to fetch latest cloud images:",l),r.error("Failed to fetch latest cloud images")}finally{A.value=!1}};return te(()=>{y(),t.value.some(i=>i.download_status==="downloading")&&V()}),oe(()=>{v&&clearInterval(v)}),{images:t,nodes:T,loading:s,showAddModal:C,showSetupModal:S,editingImage:o,imageForm:w,setupForm:h,saving:m,settingUpTemplates:b,templateStatus:M,checkingTemplates:U,fetchingLatest:A,fetchImages:y,downloadImage:B,saveImage:O,editImage:E,deleteImage:R,closeModal:L,setupTemplates:H,openSetupModal:j,checkAllTemplates:G,getTemplateCount:K,fetchLatestImages:J,formatBytes:z,formatOSType:P}}},le={class:"cloud-images-page"},ae={class:"card"},ne={class:"card-header"},de={class:"flex gap-1"},re=["disabled"],ie=["disabled"],ce={key:0,class:"template-status-section"},ue={class:"template-status-grid"},me={class:"node-status-header"},pe={class:"node-name"},ge={class:"template-count"},fe={class:"template-list"},ve={class:"template-vmid"},be={key:1,class:"loading-spinner"},he={key:2,class:"text-center text-muted"},ye={key:3,class:"table-container"},ke={class:"table"},we={class:"badge badge-info"},_e={key:0,class:"download-progress"},Ie={class:"progress-bar-container"},xe={class:"text-xs text-muted"},Fe={key:1,class:"badge badge-success"},Ce={key:2,class:"badge badge-danger"},Se={key:3,class:"badge badge-secondary"},Te={class:"flex gap-1"},Me=["onClick"],Ue={key:1,class:"btn btn-secondary btn-sm",disabled:""},Ae=["onClick"],Ne=["onClick"],De={class:"modal-header"},Le={class:"modal-body"},Ve={class:"form-group"},Be=["value"],Oe={class:"form-group"},Ee={class:"checkbox-list"},Re=["value"],ze={key:0,class:"alert alert-info"},Pe={class:"modal-footer"},qe=["disabled"],He=["disabled"],je={class:"modal-header"},Ge={class:"form-group"},Ke={class:"form-group"},Je={class:"form-group"},Qe={class:"grid grid-cols-2 gap-2"},We={class:"form-group"},Xe={class:"form-group"},Ye={class:"grid grid-cols-2 gap-2"},Ze={class:"form-group"},$e={class:"form-group"},et={class:"modal-footer"},tt=["disabled"];function ot(r,t,T,s,C,S){return n(),a("div",le,[e("div",ae,[e("div",ne,[t[23]||(t[23]=e("h3",null,"Cloud Images",-1)),e("div",de,[e("button",{onClick:t[0]||(t[0]=(...o)=>s.fetchLatestImages&&s.fetchLatestImages(...o)),class:"btn btn-outline",disabled:s.fetchingLatest},d(s.fetchingLatest?"🔄 Updating...":"⬇️ Fetch Latest"),9,re),e("button",{onClick:t[1]||(t[1]=(...o)=>s.checkAllTemplates&&s.checkAllTemplates(...o)),class:"btn btn-outline",disabled:s.checkingTemplates},d(s.checkingTemplates?"🔄 Checking...":"🔍 Check Templates"),9,ie),e("button",{onClick:t[2]||(t[2]=(...o)=>s.openSetupModal&&s.openSetupModal(...o)),class:"btn btn-outline"},"⚙️ Setup Templates"),e("button",{onClick:t[3]||(t[3]=o=>s.showAddModal=!0),class:"btn btn-primary"},"+ Add Cloud Image")])]),s.templateStatus&&Object.keys(s.templateStatus).length>0?(n(),a("div",ce,[t[24]||(t[24]=e("h4",{class:"template-status-title"},"Template Status by Node",-1)),e("div",ue,[(n(!0),a(I,null,x(s.templateStatus,o=>(n(),a("div",{key:o.node_id,class:"node-status-card"},[e("div",me,[e("span",pe,d(o.node_name),1),e("span",ge,d(s.getTemplateCount(o.templates))+" templates",1)]),e("div",fe,[(n(!0),a(I,null,x(o.templates,(m,b)=>(n(),a("div",{key:b,class:"template-item"},[e("span",ve,d(b),1),e("span",{class:X(["template-badge",m?"exists":"missing"])},d(m?"✓ Exists":"✗ Missing"),3)]))),128))])]))),128))])])):k("",!0),s.loading?(n(),a("div",be)):s.images.length===0?(n(),a("div",he,[...t[25]||(t[25]=[e("p",null,"No cloud images configured yet.",-1),e("p",{class:"text-sm"},"Add cloud images for fast VM deployment.",-1)])])):(n(),a("div",ye,[e("table",ke,[t[26]||(t[26]=e("thead",null,[e("tr",null,[e("th",null,"Name"),e("th",null,"OS Type"),e("th",null,"Version"),e("th",null,"Architecture"),e("th",null,"Size"),e("th",null,"Download Status"),e("th",null,"Actions")])],-1)),e("tbody",null,[(n(!0),a(I,null,x(s.images,o=>(n(),a("tr",{key:o.id},[e("td",null,d(o.name),1),e("td",null,[e("span",we,d(s.formatOSType(o.os_type)),1)]),e("td",null,d(o.version||"N/A"),1),e("td",null,d(o.architecture),1),e("td",null,d(o.file_size?s.formatBytes(o.file_size):"Unknown"),1),e("td",null,[o.download_status==="downloading"?(n(),a("div",_e,[e("div",Ie,[e("div",{class:"progress-bar-fill",style:Y({width:o.download_progress+"%"})},null,4)]),e("span",xe,d(o.download_progress)+"%",1)])):o.is_downloaded?(n(),a("span",Fe,"Downloaded")):o.download_status==="error"?(n(),a("span",Ce,"Error")):(n(),a("span",Se,"Not Downloaded"))]),e("td",null,[e("div",Te,[!o.is_downloaded&&o.download_status!=="downloading"?(n(),a("button",{key:0,onClick:m=>s.downloadImage(o.id),class:"btn btn-primary btn-sm"}," Download ",8,Me)):k("",!0),o.download_status==="downloading"?(n(),a("button",Ue," Downloading... ")):k("",!0),e("button",{onClick:m=>s.editImage(o),class:"btn btn-outline btn-sm"},"Edit",8,Ae),e("button",{onClick:m=>s.deleteImage(o.id),class:"btn btn-danger btn-sm"},"Delete",8,Ne)])])]))),128))])])]))]),s.showSetupModal?(n(),a("div",{key:0,class:"modal",onClick:t[10]||(t[10]=o=>s.showSetupModal=!1)},[e("div",{class:"modal-content",onClick:t[9]||(t[9]=D(()=>{},["stop"]))},[e("div",De,[t[27]||(t[27]=e("h3",null,"Setup Cloud Image Templates",-1)),e("button",{onClick:t[4]||(t[4]=o=>s.showSetupModal=!1),class:"btn-close"},"×")]),e("div",Le,[t[33]||(t[33]=e("p",{class:"text-muted mb-2"},"Automatically create cloud image templates on a Proxmox node.",-1)),e("div",Ve,[t[29]||(t[29]=e("label",{class:"form-label"},"Select Proxmox Node *",-1)),f(e("select",{"onUpdate:modelValue":t[5]||(t[5]=o=>s.setupForm.selectedNodeId=o),class:"form-control",required:""},[t[28]||(t[28]=e("option",{value:""},"Choose a node...",-1)),(n(!0),a(I,null,x(s.nodes,o=>(n(),a("option",{key:o.id,value:o.id},d(o.node_name)+" ("+d(o.proxmox_host_name)+") ",9,Be))),128))],512),[[N,s.setupForm.selectedNodeId]])]),e("div",Oe,[t[30]||(t[30]=e("label",{class:"form-label"},"Select Cloud Images to Setup *",-1)),e("div",Ee,[(n(!0),a(I,null,x(s.images,o=>(n(),a("label",{key:o.id,class:"checkbox-item"},[f(e("input",{type:"checkbox",value:o.id,"onUpdate:modelValue":t[6]||(t[6]=m=>s.setupForm.selectedImageIds=m)},null,8,Re),[[Z,s.setupForm.selectedImageIds]]),e("span",null,d(o.name)+" ("+d(o.os_type)+" "+d(o.version)+")",1)]))),128))]),t[31]||(t[31]=e("p",{class:"text-xs text-muted mt-1"},"Selected images will be created as templates 9000+",-1))]),s.settingUpTemplates?(n(),a("div",ze,[...t[32]||(t[32]=[e("p",null,"Setting up templates... This may take several minutes.",-1),e("p",{class:"text-sm"},"Templates are being downloaded and configured in the background.",-1)])])):k("",!0)]),e("div",Pe,[e("button",{onClick:t[7]||(t[7]=o=>s.showSetupModal=!1),class:"btn btn-outline",disabled:s.settingUpTemplates}," Cancel ",8,qe),e("button",{onClick:t[8]||(t[8]=(...o)=>s.setupTemplates&&s.setupTemplates(...o)),class:"btn btn-primary",disabled:!s.setupForm.selectedNodeId||s.setupForm.selectedImageIds.length===0||s.settingUpTemplates},d(s.settingUpTemplates?"Setting Up...":"Setup Templates"),9,He)])])])):k("",!0),s.showAddModal?(n(),a("div",{key:1,class:"modal",onClick:t[22]||(t[22]=o=>s.showAddModal=!1)},[e("div",{class:"modal-content",onClick:t[21]||(t[21]=D(()=>{},["stop"]))},[e("div",je,[e("h3",null,d(s.editingImage?"Edit":"Add")+" Cloud Image",1),e("button",{onClick:t[11]||(t[11]=(...o)=>s.closeModal&&s.closeModal(...o)),class:"btn-close"},"×")]),e("form",{onSubmit:t[20]||(t[20]=D((...o)=>s.saveImage&&s.saveImage(...o),["prevent"])),class:"modal-body"},[e("div",Ge,[t[34]||(t[34]=e("label",{class:"form-label"},"Display Name *",-1)),f(e("input",{"onUpdate:modelValue":t[12]||(t[12]=o=>s.imageForm.name=o),class:"form-control",required:"",placeholder:"Ubuntu 24.04 LTS Cloud Image"},null,512),[[F,s.imageForm.name]])]),e("div",Ke,[t[35]||(t[35]=e("label",{class:"form-label"},"Filename *",-1)),f(e("input",{"onUpdate:modelValue":t[13]||(t[13]=o=>s.imageForm.filename=o),class:"form-control",required:"",placeholder:"ubuntu-24.04-server-cloudimg-amd64.img"},null,512),[[F,s.imageForm.filename]]),t[36]||(t[36]=e("p",{class:"text-xs text-muted mt-1"},"The filename of the cloud image file",-1))]),e("div",Je,[t[37]||(t[37]=e("label",{class:"form-label"},"Download URL *",-1)),f(e("input",{"onUpdate:modelValue":t[14]||(t[14]=o=>s.imageForm.download_url=o),class:"form-control",required:"",type:"url",placeholder:"https://cloud-images.ubuntu.com/..."},null,512),[[F,s.imageForm.download_url]]),t[38]||(t[38]=e("p",{class:"text-xs text-muted mt-1"},"Direct download URL for the cloud image",-1))]),e("div",Qe,[e("div",We,[t[40]||(t[40]=e("label",{class:"form-label"},"OS Type *",-1)),f(e("select",{"onUpdate:modelValue":t[15]||(t[15]=o=>s.imageForm.os_type=o),class:"form-control",required:""},[...t[39]||(t[39]=[$('',9)])],512),[[N,s.imageForm.os_type]])]),e("div",Xe,[t[41]||(t[41]=e("label",{class:"form-label"},"Version",-1)),f(e("input",{"onUpdate:modelValue":t[16]||(t[16]=o=>s.imageForm.version=o),class:"form-control",placeholder:"24.04"},null,512),[[F,s.imageForm.version]])])]),e("div",Ye,[e("div",Ze,[t[43]||(t[43]=e("label",{class:"form-label"},"Architecture",-1)),f(e("select",{"onUpdate:modelValue":t[17]||(t[17]=o=>s.imageForm.architecture=o),class:"form-control"},[...t[42]||(t[42]=[e("option",{value:"amd64"},"amd64 (x86_64)",-1),e("option",{value:"arm64"},"arm64",-1)])],512),[[N,s.imageForm.architecture]])]),e("div",$e,[t[44]||(t[44]=e("label",{class:"form-label"},"SHA256 Checksum",-1)),f(e("input",{"onUpdate:modelValue":t[18]||(t[18]=o=>s.imageForm.checksum=o),class:"form-control",placeholder:"Optional"},null,512),[[F,s.imageForm.checksum]])])]),e("div",et,[e("button",{type:"button",onClick:t[19]||(t[19]=(...o)=>s.closeModal&&s.closeModal(...o)),class:"btn btn-outline"},"Cancel"),e("button",{type:"submit",disabled:s.saving,class:"btn btn-primary"},d(s.saving?"Saving...":s.editingImage?"Update":"Add"),9,tt)])],32)])])):k("",!0)])}const lt=W(se,[["render",ot],["__scopeId","data-v-f1a8c3e0"]]);export{lt as default}; diff --git a/src/frontend/dist/assets/CreateVM-B49KqhzI.css b/src/frontend/dist/assets/CreateVM-B49KqhzI.css new file mode 100644 index 0000000..9990551 --- /dev/null +++ b/src/frontend/dist/assets/CreateVM-B49KqhzI.css @@ -0,0 +1 @@ +.create-vm-form[data-v-d016d938]{padding:1.5rem}.form-section[data-v-d016d938]{margin-bottom:2rem;padding-bottom:2rem;border-bottom:1px solid var(--border-color)}.form-section[data-v-d016d938]:last-of-type{border-bottom:none}.section-title[data-v-d016d938]{font-size:1.125rem;font-weight:600;margin-bottom:1rem;color:var(--text-primary)}.datacenter-cards[data-v-d016d938],.node-cards[data-v-d016d938]{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:1rem;margin-top:.5rem}.datacenter-card[data-v-d016d938],.node-card[data-v-d016d938]{border:2px solid var(--border-color);border-radius:.5rem;padding:1rem;cursor:pointer;transition:all .2s;background:var(--background)}.datacenter-card[data-v-d016d938]:hover,.node-card[data-v-d016d938]:hover{border-color:var(--primary-color);box-shadow:0 4px 6px #0000001a;transform:translateY(-2px)}.datacenter-card.selected[data-v-d016d938],.node-card.selected[data-v-d016d938]{border-color:var(--primary-color);background:linear-gradient(135deg,#2563eb1a,#9333ea1a)}.datacenter-card[data-v-d016d938]{display:flex;align-items:center;gap:1rem}.datacenter-icon[data-v-d016d938]{font-size:2.5rem;flex-shrink:0}.datacenter-info h5[data-v-d016d938]{margin:0 0 .25rem;font-size:1.125rem}.node-header[data-v-d016d938]{display:flex;justify-content:space-between;align-items:center;margin-bottom:.75rem;padding-bottom:.5rem;border-bottom:1px solid var(--border-color)}.node-header h6[data-v-d016d938]{margin:0;font-size:1rem;font-weight:600}.node-resources[data-v-d016d938]{display:flex;flex-direction:column;gap:.5rem}.resource-item[data-v-d016d938]{display:flex;align-items:center;gap:.5rem;font-size:.875rem}.resource-icon[data-v-d016d938]{font-size:1rem}.storage-cards[data-v-d016d938],.network-cards[data-v-d016d938]{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:1rem;margin-top:.5rem}.storage-card[data-v-d016d938],.network-card[data-v-d016d938]{border:2px solid var(--border-color);border-radius:.5rem;padding:1rem;cursor:pointer;transition:all .2s;background:var(--background);position:relative}.storage-card[data-v-d016d938]:hover,.network-card[data-v-d016d938]:hover{border-color:var(--primary-color);box-shadow:0 4px 6px #0000001a}.storage-card.selected[data-v-d016d938],.network-card.selected[data-v-d016d938]{border-color:var(--primary-color);background:linear-gradient(135deg,#2563eb1a,#9333ea1a)}.storage-card.disabled[data-v-d016d938],.network-card.disabled[data-v-d016d938]{opacity:.5;cursor:not-allowed}.storage-header[data-v-d016d938],.network-header[data-v-d016d938]{display:flex;justify-content:space-between;align-items:center;margin-bottom:.75rem}.storage-header h6[data-v-d016d938],.network-header h6[data-v-d016d938]{margin:0;font-size:1rem;font-weight:600}.storage-bar[data-v-d016d938]{width:100%;height:8px;background:var(--border-color);border-radius:4px;overflow:hidden;margin-bottom:.5rem}.storage-bar-fill[data-v-d016d938]{height:100%;background:linear-gradient(90deg,var(--primary-color),var(--secondary-color));transition:width .3s}.storage-stats[data-v-d016d938]{display:flex;justify-content:space-between;font-size:.75rem;color:var(--text-secondary)}.storage-badge[data-v-d016d938]{position:absolute;top:.5rem;right:.5rem}.network-info[data-v-d016d938]{display:flex;flex-direction:column;gap:.25rem}.loading-message[data-v-d016d938]{text-align:center;padding:2rem;color:var(--text-secondary)}.badge-sm[data-v-d016d938]{padding:.125rem .5rem;font-size:.75rem}.form-actions[data-v-d016d938]{display:flex;gap:1rem;padding-top:1rem}.modal[data-v-d016d938]{position:fixed;top:0;left:0;right:0;bottom:0;background-color:#000000b3;display:flex;align-items:center;justify-content:center;z-index:1000}.modal[style*="display: none"][data-v-d016d938]{display:none!important}.progress-modal[data-v-d016d938]{background:#fff;border-radius:.5rem;max-width:500px;width:90%;box-shadow:var(--shadow-lg)}.modal-header[data-v-d016d938]{padding:1.5rem;border-bottom:1px solid var(--border-color)}.modal-header h3[data-v-d016d938]{margin:0;font-size:1.5rem;font-weight:600}.modal-body[data-v-d016d938]{padding:2rem 1.5rem}.modal-footer[data-v-d016d938]{padding:1rem 1.5rem;border-top:1px solid var(--border-color);display:flex;justify-content:flex-end}.progress-container[data-v-d016d938]{text-align:center}.spinner-container[data-v-d016d938]{margin:0 auto 1.5rem;width:64px;height:64px}.spinner[data-v-d016d938]{width:64px;height:64px;border:4px solid var(--border-color);border-top-color:var(--primary-color);border-radius:50%;animation:spin-d016d938 1s linear infinite}@keyframes spin-d016d938{to{transform:rotate(360deg)}}.success-icon[data-v-d016d938]{width:64px;height:64px;line-height:64px;margin:0 auto 1.5rem;font-size:3rem;color:#10b981;background:#d1fae5;border-radius:50%}.error-icon[data-v-d016d938]{width:64px;height:64px;line-height:64px;margin:0 auto 1.5rem;font-size:3rem;color:#ef4444;background:#fee2e2;border-radius:50%}.progress-vm-name[data-v-d016d938]{font-size:1.25rem;font-weight:600;margin-bottom:1rem;color:var(--text-primary)}.progress-steps[data-v-d016d938]{margin:1.5rem 0;padding:1.25rem;background:#f8fafc;border-radius:.5rem;border-left:4px solid #3b82f6}.current-step[data-v-d016d938]{font-size:1.125rem;font-weight:500;color:#1e40af;line-height:1.6;animation:pulse-text-d016d938 2s ease-in-out infinite}@keyframes pulse-text-d016d938{0%,to{opacity:1}50%{opacity:.7}}.progress-vmid[data-v-d016d938]{font-size:.875rem;color:var(--text-secondary);font-family:monospace;margin-top:.5rem}.progress-error[data-v-d016d938]{margin-top:1rem;padding:1rem;background-color:#fee2e2;border:1px solid #ef4444;border-radius:.375rem;color:#991b1b;text-align:left}.progress-error strong[data-v-d016d938]{display:block;margin-bottom:.5rem}.progress-bar-container[data-v-d016d938]{margin:1.5rem 0}.progress-bar-background[data-v-d016d938]{width:100%;height:32px;background-color:#e5e7eb;border-radius:16px;overflow:hidden;box-shadow:inset 0 2px 4px #0000001a}.progress-bar-fill[data-v-d016d938]{height:100%;background:linear-gradient(90deg,#3b82f6,#2563eb);transition:width .3s ease;display:flex;align-items:center;justify-content:center;position:relative;box-shadow:0 2px 4px #3b82f64d}.progress-bar-fill[data-v-d016d938]:after{content:"";position:absolute;top:0;left:0;right:0;bottom:0;background:linear-gradient(90deg,transparent,rgba(255,255,255,.3),transparent);animation:shimmer-d016d938 2s infinite}@keyframes shimmer-d016d938{0%{transform:translate(-100%)}to{transform:translate(100%)}}.progress-bar-text[data-v-d016d938]{color:#fff;font-weight:600;font-size:.875rem;z-index:1;text-shadow:0 1px 2px rgba(0,0,0,.3)}.form-help[data-v-d016d938]{display:block;margin-top:.25rem;font-size:.875rem;color:var(--text-secondary);opacity:.8}.collapsible-header[data-v-d016d938]{cursor:pointer;-webkit-user-select:none;user-select:none;display:flex;align-items:center;gap:.5rem;padding:.75rem;margin:-.75rem;border-radius:6px;transition:background-color .2s}.collapsible-header[data-v-d016d938]:hover{background-color:#3b82f60d}.toggle-icon[data-v-d016d938]{font-size:.875rem;color:#3b82f6;transition:transform .2s;display:inline-block;width:1rem}.collapsible-content[data-v-d016d938]{margin-top:1rem;animation:slideDown-d016d938 .3s ease-out}@keyframes slideDown-d016d938{0%{opacity:0;transform:translateY(-10px)}to{opacity:1;transform:translateY(0)}} diff --git a/src/frontend/dist/assets/CreateVM-D6qAFo1d.js b/src/frontend/dist/assets/CreateVM-D6qAFo1d.js new file mode 100644 index 0000000..85225a0 --- /dev/null +++ b/src/frontend/dist/assets/CreateVM-D6qAFo1d.js @@ -0,0 +1 @@ +import{_ as yo,g as _o,c as i,o as n,a as e,z as wo,w as ko,d as g,e as r,v,A as _,B as h,C as X,F as w,p as P,t as u,b as c,m as k,n as G,D as B,E as D,h as Do,i as So,T as xo,u as Co,x as Vo,r as p,j as O,y as Mo,k as Po,l as S}from"./index-DpP9cWht.js";const Uo={name:"CreateVM",setup(){const R=Co(),o=Vo(),E=p([]),t=p([]),L=p([]),H=p([]),x=p("iso"),s=p([]),b=p([]),W=p(""),C=p(""),V=p(""),Y=p(null),F=p(!1),T=p(!1),q=p(!1),z=p(!1),A=p(!0),N=p(!1),Q=p(!1),K=p(!1),j=p(!1),J=p(!1),I=p({name:"",status:"creating",status_message:"",error_message:"",vmid:null});let M=null;const Z=O(()=>[...s.value].sort((l,d)=>l.storage.localeCompare(d.storage,void 0,{numeric:!0,sensitivity:"base"}))),$=O(()=>[...s.value].filter(l=>l.content&&l.content.includes("iso")).sort((l,d)=>l.storage.localeCompare(d.storage,void 0,{numeric:!0,sensitivity:"base"}))),oo=O(()=>[...b.value].sort((l,d)=>l.iface.localeCompare(d.iface,void 0,{numeric:!0,sensitivity:"base"}))),a=p({name:"",hostname:"",proxmox_host_id:null,node_id:null,iso_id:null,cloud_image_id:null,os_type:"",cpu_sockets:1,cpu_cores:2,cpu_type:"x86-64-v2-AES",cpu_flags:"",cpu_limit:null,cpu_units:1024,numa_enabled:!1,memory:2048,balloon:null,shares:null,disk_size:20,storage:"",iso_storage:"",scsihw:"virtio-scsi-pci",bios_type:"seabios",machine_type:"pc",vga_type:"std",boot_order:"cdn",onboot:!0,tablet:!0,hotplug:null,protection:!1,startup_order:null,startup_up:null,startup_down:null,kvm:!0,acpi:!0,agent_enabled:!0,network_bridge:"",network_interfaces:[],vlan_tag:null,ip_address:"",gateway:"",netmask:"24",dns_servers:"8.8.8.8,8.8.4.4",username:"administrator",password:"",ssh_key:"",description:"",tags:""}),eo=async()=>{try{const l=await S.proxmox.listHosts();E.value=l.data.filter(d=>d.is_active)}catch(l){console.error("Failed to load hosts:",l)}},so=async l=>{W.value=l,a.value.proxmox_host_id=l,a.value.node_id=null,t.value=[],s.value=[],b.value=[],C.value="",V.value="",F.value=!0;try{const d=await S.proxmox.listNodes(l);t.value=d.data}catch(d){console.error("Failed to load nodes:",d),o.error("Failed to load nodes. Try polling the host first.")}finally{F.value=!1}},to=async l=>{a.value.node_id=l,C.value="",V.value="",a.value.storage="",a.value.iso_storage="",await Promise.all([ao(l),lo(l)])},ao=async l=>{T.value=!0;try{const d=await S.proxmox.getNodeStorage(l);if(s.value=d.data.storage.filter(m=>m.content&&m.content.includes("images")).sort((m,y)=>m.storage.localeCompare(y.storage,void 0,{numeric:!0,sensitivity:"base"})),s.value.length>0){const m=s.value.find(y=>y.enabled&&y.active)||s.value[0];C.value=m.storage,a.value.storage=m.storage}const f=d.data.storage.filter(m=>m.content&&m.content.includes("iso")&&m.enabled&&m.active).sort((m,y)=>m.storage.localeCompare(y.storage,void 0,{numeric:!0,sensitivity:"base"}));f.length>0&&(a.value.iso_storage=f[0].storage)}catch(d){console.error("Failed to load storage:",d),o.error("Failed to load storage pools")}finally{T.value=!1}},lo=async l=>{q.value=!0;try{const d=await S.proxmox.getNodeNetwork(l);if(b.value=d.data.network.sort((f,m)=>f.iface.localeCompare(m.iface,void 0,{numeric:!0,sensitivity:"base"})),b.value.length>0){const f=b.value.find(m=>m.active)||b.value[0];V.value=f.iface,a.value.network_bridge=f.iface}}catch(d){console.error("Failed to load network:",d),o.error("Failed to load network interfaces")}finally{q.value=!1}},ro=l=>{const d=s.value.find(f=>f.storage===l);d&&d.enabled&&d.active&&(C.value=l,a.value.storage=l)},no=l=>{const d=b.value.find(f=>f.iface===l);d&&d.active&&(V.value=l,a.value.network_bridge=l)},io=l=>!l.total||l.total===0?0:Math.round(l.used/l.total*100),uo=async()=>{try{const l=await S.isos.list();L.value=l.data}catch(l){console.error("Failed to load ISOs:",l)}},mo=async()=>{try{const l=await S.cloudImages.list();H.value=l.data}catch(l){console.error("Failed to load cloud images:",l)}},vo=async l=>{try{const d=await S.vms.getProgress(l);console.log("Progress update received:",d.data),I.value=d.data,(d.data.status==="running"||d.data.status==="stopped"||d.data.status==="error")&&(console.log("VM deployment finished with status:",d.data.status),M&&(clearInterval(M),M=null))}catch(d){console.error("Failed to poll progress:",d)}},po=()=>{N.value=!1,M&&(clearInterval(M),M=null),I.value.status==="running"&&R.push("/vms")},go=()=>{const l=[];return(!a.value.name||a.value.name.trim()==="")&&l.push("VM name is required"),(!a.value.hostname||a.value.hostname.trim()==="")&&l.push("Hostname is required"),a.value.os_type||l.push("Operating system must be selected"),a.value.proxmox_host_id||l.push("Proxmox datacenter must be selected"),a.value.node_id||l.push("Node must be selected"),C.value||l.push("Storage pool must be selected"),V.value||l.push("Network bridge must be selected"),x.value==="iso"&&a.value.iso_id===null?a.value.cloud_image_id:x.value==="cloud_image"&&!a.value.cloud_image_id&&l.push("Cloud image must be selected"),(!a.value.cpu_cores||a.value.cpu_cores<1)&&l.push("CPU cores must be at least 1"),(!a.value.memory||a.value.memory<512)&&l.push("Memory must be at least 512 MB"),(!a.value.disk_size||a.value.disk_size<10)&&l.push("Disk size must be at least 10 GB"),(!a.value.username||a.value.username.trim()==="")&&l.push("Username is required"),(!a.value.password||a.value.password.trim()==="")&&l.push("Password is required"),A.value||((!a.value.ip_address||a.value.ip_address.trim()==="")&&l.push("IP address is required when not using DHCP"),(!a.value.gateway||a.value.gateway.trim()==="")&&l.push("Gateway is required when not using DHCP")),l},co=async()=>{var d,f;const l=go();if(l.length>0){l.forEach(m=>{o.error(m)});return}z.value=!0;try{const m={name:a.value.name,hostname:a.value.hostname,proxmox_host_id:a.value.proxmox_host_id,node_id:a.value.node_id,iso_id:x.value==="iso"?a.value.iso_id:null,cloud_image_id:x.value==="cloud_image"?a.value.cloud_image_id:null,os_type:a.value.os_type,cpu_sockets:a.value.cpu_sockets,cpu_cores:a.value.cpu_cores,cpu_type:a.value.cpu_type||"host",cpu_flags:a.value.cpu_flags||null,cpu_limit:a.value.cpu_limit||null,cpu_units:a.value.cpu_units||1024,numa_enabled:a.value.numa_enabled||!1,memory:a.value.memory,balloon:a.value.balloon||null,shares:a.value.shares||null,disk_size:a.value.disk_size,storage:C.value||null,iso_storage:a.value.iso_storage||null,scsihw:a.value.scsihw||"virtio-scsi-pci",bios_type:a.value.bios_type||"seabios",machine_type:a.value.machine_type||"pc",vga_type:a.value.vga_type||"std",boot_order:a.value.boot_order||"cdn",onboot:a.value.onboot!==void 0?a.value.onboot:!0,tablet:a.value.tablet!==void 0?a.value.tablet:!0,hotplug:a.value.hotplug||null,protection:a.value.protection||!1,startup_order:a.value.startup_order||null,startup_up:a.value.startup_up||null,startup_down:a.value.startup_down||null,kvm:a.value.kvm!==void 0?a.value.kvm:!0,acpi:a.value.acpi!==void 0?a.value.acpi:!0,agent_enabled:a.value.agent_enabled!==void 0?a.value.agent_enabled:!0,network_bridge:V.value||null,network_interfaces:a.value.network_interfaces||null,username:a.value.username,password:a.value.password,ssh_key:a.value.ssh_key||null,description:a.value.description||null,tags:a.value.tags||null};A.value?(m.ip_address=null,m.gateway=null,m.netmask=null,m.dns_servers=null):(m.ip_address=a.value.ip_address||null,m.gateway=a.value.gateway||null,m.netmask=a.value.netmask||null,m.dns_servers=a.value.dns_servers||null),console.log("Creating VM with payload:",JSON.stringify(m,null,2));const y=await S.vms.create(m);console.log("VM created successfully, response:",y.data),console.log("Setting showProgressModal to true"),N.value=!0,I.value={name:a.value.name,status:"creating",status_message:"Initializing VM deployment...",error_message:"",vmid:null},console.log("Progress modal should be visible now, showProgressModal:",N.value);const U=y.data.id;console.log("Starting progress polling for VM ID:",U),M=setInterval(()=>{vo(U)},1e3),o.success("VM creation started!")}catch(m){if(console.error("Failed to create VM:",m),(f=(d=m.response)==null?void 0:d.data)!=null&&f.detail)if(Array.isArray(m.response.data.detail)){const y=m.response.data.detail.map(U=>`${U.loc[U.loc.length-1]}: ${U.msg}`).join(", ");o.error(`Validation error: ${y}`)}else o.error(`Error: ${m.response.data.detail}`);else o.error("Failed to create VM. Check the form and try again.")}finally{z.value=!1}},fo=l=>l?(l/(1024*1024*1024)).toFixed(2)+" GB":"0 B";Mo(A,l=>{l&&(a.value.ip_address="",a.value.gateway="",a.value.netmask="24",a.value.dns_servers="8.8.8.8,8.8.4.4")});const bo=O(()=>{const d=(I.value.status_message||"").match(/(\d+)%/);return d?parseInt(d[1]):0});return Po(()=>{eo(),uo(),mo()}),{hosts:E,nodes:t,isos:L,cloudImages:H,imageSource:x,storageList:s,networkList:b,sortedStorageList:Z,sortedISOStorageList:$,sortedNetworkList:oo,selectedHostId:W,selectedStorage:C,selectedBridge:V,vlanTag:Y,loadingNodes:F,showProgressModal:N,progressData:I,uploadProgress:bo,closeProgressModal:po,loadingStorage:T,loadingNetwork:q,formData:a,creating:z,useDHCP:A,selectDatacenter:so,selectNode:to,selectStorage:ro,selectBridge:no,getStorageUsagePercent:io,createVM:co,formatBytes:fo,showAdvancedHardware:Q,showBootStartup:K,showSystemOptions:j,showMetadata:J}}},Io={class:"create-vm-page"},ho={class:"card"},Bo={class:"form-section"},Ao={class:"grid grid-cols-2 gap-2"},No={class:"form-group"},Oo={class:"form-group"},Eo={class:"grid grid-cols-2 gap-2"},Lo={class:"form-group"},Ho={class:"form-group"},Fo={class:"radio-group",style:{display:"flex",gap:"1rem","margin-bottom":"0.5rem"}},To={class:"radio-label"},qo={class:"radio-label"},zo=["value"],Go=["value"],Ro={key:0},Wo={class:"form-section"},Xo={class:"datacenter-selector"},Yo={class:"form-group"},Qo={class:"datacenter-cards"},Ko=["onClick"],jo={class:"datacenter-info"},Jo={class:"text-xs"},Zo={key:0,class:"form-group"},$o={class:"node-cards"},oe=["onClick"],ee={class:"node-header"},se={class:"node-resources"},te={class:"resource-item"},ae={class:"resource-item"},le={class:"resource-item"},re={key:1,class:"loading-message"},ne={key:0,class:"form-section"},ie={key:0,class:"loading-message"},de={key:1,class:"text-muted text-center"},ue={key:2,class:"form-group"},me={class:"storage-cards"},ve=["onClick"],pe={class:"storage-header"},ge={class:"badge badge-sm badge-info"},ce={class:"storage-info"},fe={class:"storage-bar"},be={class:"storage-stats"},ye={key:0,class:"storage-badge"},_e={key:1,class:"form-section"},we={key:0,class:"loading-message"},ke={key:1,class:"text-muted text-center"},De={key:2,class:"form-group"},Se={class:"storage-cards"},xe=["onClick"],Ce={class:"storage-header"},Ve={class:"badge badge-sm badge-info"},Me={class:"storage-info"},Pe={class:"storage-bar"},Ue={class:"storage-stats"},Ie={key:0,class:"storage-badge"},he={key:2,class:"form-section"},Be={key:0,class:"loading-message"},Ae={key:1,class:"text-muted text-center"},Ne={key:2},Oe={class:"form-group"},Ee={class:"network-cards"},Le=["onClick"],He={class:"network-header"},Fe={class:"network-info"},Te={key:0,class:"text-xs"},qe={key:1,class:"text-xs"},ze={key:2,class:"text-xs"},Ge={class:"form-group"},Re={class:"form-section"},We={class:"grid grid-cols-4 gap-2"},Xe={class:"form-group"},Ye={class:"form-group"},Qe={class:"form-group"},Ke={class:"form-group"},je={class:"form-section"},Je={class:"toggle-icon"},Ze={class:"collapsible-content"},$e={class:"grid grid-cols-3 gap-2"},os={class:"form-group"},es={class:"form-group"},ss={class:"form-group"},ts={class:"grid grid-cols-3 gap-2"},as={class:"form-group"},ls={class:"form-group"},rs={class:"form-group"},ns={class:"form-label"},is={class:"form-group"},ds={class:"grid grid-cols-2 gap-2"},us={class:"form-group"},ms={class:"form-group"},vs={class:"grid grid-cols-2 gap-2"},ps={class:"form-group"},gs={class:"form-group"},cs={class:"grid grid-cols-2 gap-2"},fs={class:"form-group"},bs={class:"form-group"},ys={class:"form-label"},_s={class:"form-section"},ws={class:"toggle-icon"},ks={class:"collapsible-content"},Ds={class:"form-group"},Ss={class:"form-label"},xs={class:"grid grid-cols-3 gap-2"},Cs={class:"form-group"},Vs={class:"form-group"},Ms={class:"form-group"},Ps={class:"form-section"},Us={class:"toggle-icon"},Is={class:"collapsible-content"},hs={class:"grid grid-cols-2 gap-2"},Bs={class:"form-group"},As={class:"form-group"},Ns={class:"form-label"},Os={class:"grid grid-cols-3 gap-2"},Es={class:"form-group"},Ls={class:"form-label"},Hs={class:"form-group"},Fs={class:"form-label"},Ts={class:"form-group"},qs={class:"form-label"},zs={class:"form-section"},Gs={class:"toggle-icon"},Rs={class:"collapsible-content"},Ws={class:"form-group"},Xs={class:"form-group"},Ys={class:"form-section"},Qs={class:"form-group"},Ks={class:"form-label"},js={key:0,class:"grid grid-cols-2 gap-2"},Js={class:"form-group"},Zs={class:"form-group"},$s={class:"form-group"},ot={class:"form-group"},et={class:"form-section"},st={class:"grid grid-cols-2 gap-2"},tt={class:"form-group"},at={class:"form-group"},lt={class:"form-group"},rt={class:"form-actions"},nt=["disabled"],it={class:"modal"},dt={class:"modal-content progress-modal"},ut={class:"modal-header"},mt={class:"modal-body"},vt={class:"progress-container"},pt={key:0,class:"spinner-container"},gt={key:1,class:"success-icon"},ct={key:2,class:"error-icon"},ft={class:"progress-vm-name"},bt={class:"progress-steps"},yt={class:"current-step"},_t={key:3,class:"progress-bar-container"},wt={class:"progress-bar-background"},kt={class:"progress-bar-text"},Dt={key:4,class:"progress-vmid"},St={key:5,class:"progress-error"},xt={class:"modal-footer"};function Ct(R,o,E,t,L,H){const x=_o("router-link");return n(),i(w,null,[e("div",Io,[e("div",ho,[o[156]||(o[156]=e("div",{class:"card-header"},[e("h3",null,"Create Virtual Machine")],-1)),e("form",{onSubmit:o[48]||(o[48]=ko((...s)=>t.createVM&&t.createVM(...s),["prevent"])),class:"create-vm-form"},[e("div",Bo,[o[59]||(o[59]=e("h4",{class:"section-title"},"Basic Information",-1)),e("div",Ao,[e("div",No,[o[50]||(o[50]=e("label",{class:"form-label"},"VM Name *",-1)),r(e("input",{"onUpdate:modelValue":o[0]||(o[0]=s=>t.formData.name=s),class:"form-control",required:""},null,512),[[v,t.formData.name]])]),e("div",Oo,[o[51]||(o[51]=e("label",{class:"form-label"},"Hostname *",-1)),r(e("input",{"onUpdate:modelValue":o[1]||(o[1]=s=>t.formData.hostname=s),class:"form-control",required:""},null,512),[[v,t.formData.hostname]])])]),e("div",Eo,[e("div",Lo,[o[53]||(o[53]=e("label",{class:"form-label"},"Operating System *",-1)),r(e("select",{"onUpdate:modelValue":o[2]||(o[2]=s=>t.formData.os_type=s),class:"form-control",required:""},[...o[52]||(o[52]=[h('',5)])],512),[[_,t.formData.os_type]])]),e("div",Ho,[o[58]||(o[58]=e("label",{class:"form-label"},"Installation Method *",-1)),e("div",Fo,[e("label",To,[r(e("input",{type:"radio","onUpdate:modelValue":o[3]||(o[3]=s=>t.imageSource=s),value:"iso"},null,512),[[X,t.imageSource]]),o[54]||(o[54]=e("span",null,"ISO Image",-1))]),e("label",qo,[r(e("input",{type:"radio","onUpdate:modelValue":o[4]||(o[4]=s=>t.imageSource=s),value:"cloud_image"},null,512),[[X,t.imageSource]]),o[55]||(o[55]=e("span",null,"Cloud Image (Fast)",-1))])]),t.imageSource==="iso"?r((n(),i("select",{key:0,"onUpdate:modelValue":o[5]||(o[5]=s=>t.formData.iso_id=s),class:"form-control"},[o[56]||(o[56]=e("option",{value:null},"No ISO (cloud-init only)",-1)),(n(!0),i(w,null,P(t.isos,s=>(n(),i("option",{key:s.id,value:s.id},u(s.name)+" ("+u(s.version)+") ",9,zo))),128))],512)),[[_,t.formData.iso_id]]):g("",!0),t.imageSource==="cloud_image"?r((n(),i("select",{key:1,"onUpdate:modelValue":o[6]||(o[6]=s=>t.formData.cloud_image_id=s),class:"form-control",required:""},[o[57]||(o[57]=e("option",{value:null},"Select Cloud Image",-1)),(n(!0),i(w,null,P(t.cloudImages,s=>(n(),i("option",{key:s.id,value:s.id},[c(u(s.name)+" "+u(s.version?"("+s.version+")":"")+" ",1),s.is_downloaded?g("",!0):(n(),i("span",Ro," - Not Downloaded"))],8,Go))),128))],512)),[[_,t.formData.cloud_image_id]]):g("",!0)])])]),e("div",Wo,[o[67]||(o[67]=e("h4",{class:"section-title"},"Datacenter & Node Selection",-1)),e("div",Xo,[e("div",Yo,[o[61]||(o[61]=e("label",{class:"form-label"},"Select Proxmox Datacenter *",-1)),e("div",Qo,[(n(!0),i(w,null,P(t.hosts,s=>(n(),i("div",{key:s.id,class:k(["datacenter-card",{selected:t.selectedHostId===s.id}]),onClick:b=>t.selectDatacenter(s.id)},[o[60]||(o[60]=e("div",{class:"datacenter-icon"},"🏢",-1)),e("div",jo,[e("h5",null,u(s.name),1),e("p",Jo,u(s.hostname)+":"+u(s.port),1),e("span",{class:k(["badge",s.is_active?"badge-success":"badge-danger"])},u(s.is_active?"Active":"Inactive"),3)])],10,Ko))),128))])]),t.selectedHostId&&t.nodes.length>0?(n(),i("div",Zo,[o[65]||(o[65]=e("label",{class:"form-label"},"Select Node *",-1)),e("div",$o,[(n(!0),i(w,null,P(t.nodes,s=>(n(),i("div",{key:s.id,class:k(["node-card",{selected:t.formData.node_id===s.id}]),onClick:b=>t.selectNode(s.id)},[e("div",ee,[e("h6",null,u(s.node_name),1),e("span",{class:k(["badge","badge-sm",s.status==="online"?"badge-success":"badge-danger"])},u(s.status),3)]),e("div",se,[e("div",te,[o[62]||(o[62]=e("span",{class:"resource-icon"},"🖥️",-1)),e("span",null,u(s.cpu_cores)+" cores ("+u(s.cpu_usage)+"%)",1)]),e("div",ae,[o[63]||(o[63]=e("span",{class:"resource-icon"},"💾",-1)),e("span",null,u(t.formatBytes(s.memory_total))+" RAM",1)]),e("div",le,[o[64]||(o[64]=e("span",{class:"resource-icon"},"💿",-1)),e("span",null,u(t.formatBytes(s.disk_total))+" Disk",1)])])],10,oe))),128))])])):g("",!0),t.selectedHostId&&t.loadingNodes?(n(),i("div",re,[...o[66]||(o[66]=[e("div",{class:"loading-spinner"},null,-1),e("p",null,"Loading nodes...",-1)])])):g("",!0)])]),t.formData.node_id?(n(),i("div",ne,[o[72]||(o[72]=e("h4",{class:"section-title"},"Storage Configuration",-1)),t.loadingStorage?(n(),i("div",ie,[...o[68]||(o[68]=[e("div",{class:"loading-spinner"},null,-1),e("p",null,"Loading storage pools...",-1)])])):t.storageList.length===0?(n(),i("div",de,[...o[69]||(o[69]=[e("p",null,"No storage pools available",-1)])])):(n(),i("div",ue,[o[71]||(o[71]=e("label",{class:"form-label"},"Storage Pool *",-1)),e("div",me,[(n(!0),i(w,null,P(t.sortedStorageList,s=>(n(),i("div",{key:s.storage,class:k(["storage-card",{selected:t.selectedStorage===s.storage,disabled:!s.enabled||!s.active}]),onClick:b=>t.selectStorage(s.storage)},[e("div",pe,[e("h6",null,u(s.storage),1),e("span",ge,u(s.type),1)]),e("div",ce,[e("div",fe,[e("div",{class:"storage-bar-fill",style:G({width:t.getStorageUsagePercent(s)+"%"})},null,4)]),e("div",be,[e("span",null,u(t.formatBytes(s.available))+" free",1),e("span",null,u(t.formatBytes(s.total))+" total",1)])]),s.shared?(n(),i("div",ye,[...o[70]||(o[70]=[e("span",{class:"badge badge-success"},"Shared",-1)])])):g("",!0)],10,ve))),128))])]))])):g("",!0),t.formData.node_id?(n(),i("div",_e,[o[78]||(o[78]=e("h4",{class:"section-title"},"ISO Storage",-1)),o[79]||(o[79]=e("p",{class:"section-description"},"Select where installation media (ISO files) will be stored",-1)),t.loadingStorage?(n(),i("div",we,[...o[73]||(o[73]=[e("div",{class:"loading-spinner"},null,-1),e("p",null,"Loading storage pools...",-1)])])):t.storageList.length===0?(n(),i("div",ke,[...o[74]||(o[74]=[e("p",null,"No storage pools available",-1)])])):(n(),i("div",De,[o[76]||(o[76]=e("label",{class:"form-label"},"ISO Storage Pool *",-1)),e("div",Se,[(n(!0),i(w,null,P(t.sortedISOStorageList,s=>(n(),i("div",{key:"iso-"+s.storage,class:k(["storage-card",{selected:t.formData.iso_storage===s.storage,disabled:!s.enabled||!s.active}]),onClick:b=>t.formData.iso_storage=s.storage},[e("div",Ce,[e("h6",null,u(s.storage),1),e("span",Ve,u(s.type),1)]),e("div",Me,[e("div",Pe,[e("div",{class:"storage-bar-fill",style:G({width:t.getStorageUsagePercent(s)+"%"})},null,4)]),e("div",Ue,[e("span",null,u(t.formatBytes(s.used))+" / "+u(t.formatBytes(s.total)),1),e("span",null,u(t.getStorageUsagePercent(s))+"% used",1)])]),s.shared?(n(),i("div",Ie,[...o[75]||(o[75]=[e("span",{class:"badge badge-success"},"Shared",-1)])])):g("",!0)],10,xe))),128))]),o[77]||(o[77]=e("p",{class:"help-text"},"Only showing storage pools that support ISO content",-1))]))])):g("",!0),t.formData.node_id?(n(),i("div",he,[o[88]||(o[88]=e("h4",{class:"section-title"},"Network Configuration",-1)),t.loadingNetwork?(n(),i("div",Be,[...o[80]||(o[80]=[e("div",{class:"loading-spinner"},null,-1),e("p",null,"Loading network interfaces...",-1)])])):t.networkList.length===0?(n(),i("div",Ae,[...o[81]||(o[81]=[e("p",null,"No network bridges available",-1)])])):(n(),i("div",Ne,[e("div",Oe,[o[85]||(o[85]=e("label",{class:"form-label"},"Network Bridge *",-1)),e("div",Ee,[(n(!0),i(w,null,P(t.sortedNetworkList,s=>(n(),i("div",{key:s.iface,class:k(["network-card",{selected:t.selectedBridge===s.iface,disabled:!s.active}]),onClick:b=>t.selectBridge(s.iface)},[e("div",He,[e("h6",null,u(s.iface),1),e("span",{class:k(["badge","badge-sm",s.active?"badge-success":"badge-danger"])},u(s.active?"Active":"Inactive"),3)]),e("div",Fe,[s.address?(n(),i("div",Te,[o[82]||(o[82]=e("strong",null,"IP:",-1)),c(" "+u(s.address)+u(s.netmask?"/"+s.netmask:""),1)])):g("",!0),s.gateway?(n(),i("div",qe,[o[83]||(o[83]=e("strong",null,"Gateway:",-1)),c(" "+u(s.gateway),1)])):g("",!0),s.bridge_ports?(n(),i("div",ze,[o[84]||(o[84]=e("strong",null,"Ports:",-1)),c(" "+u(s.bridge_ports),1)])):g("",!0)])],10,Le))),128))])]),e("div",Ge,[o[86]||(o[86]=e("label",{class:"form-label"},"VLAN Tag (optional)",-1)),r(e("input",{"onUpdate:modelValue":o[7]||(o[7]=s=>t.vlanTag=s),type:"number",min:"1",max:"4094",class:"form-control",placeholder:"Leave empty for no VLAN"},null,512),[[v,t.vlanTag,void 0,{number:!0}]]),o[87]||(o[87]=e("p",{class:"text-xs text-muted"},"Enter VLAN ID (1-4094) or leave empty for untagged",-1))])]))])):g("",!0),e("div",Re,[o[93]||(o[93]=e("h4",{class:"section-title"},"Resource Allocation",-1)),e("div",We,[e("div",Xe,[o[89]||(o[89]=e("label",{class:"form-label"},"CPU Sockets *",-1)),r(e("input",{"onUpdate:modelValue":o[8]||(o[8]=s=>t.formData.cpu_sockets=s),type:"number",min:"1",max:"8",class:"form-control",required:""},null,512),[[v,t.formData.cpu_sockets,void 0,{number:!0}]])]),e("div",Ye,[o[90]||(o[90]=e("label",{class:"form-label"},"CPU Cores *",-1)),r(e("input",{"onUpdate:modelValue":o[9]||(o[9]=s=>t.formData.cpu_cores=s),type:"number",min:"1",max:"64",class:"form-control",required:""},null,512),[[v,t.formData.cpu_cores,void 0,{number:!0}]])]),e("div",Qe,[o[91]||(o[91]=e("label",{class:"form-label"},"Memory (MB) *",-1)),r(e("input",{"onUpdate:modelValue":o[10]||(o[10]=s=>t.formData.memory=s),type:"number",min:"512",step:"512",class:"form-control",required:""},null,512),[[v,t.formData.memory,void 0,{number:!0}]])]),e("div",Ke,[o[92]||(o[92]=e("label",{class:"form-label"},"Disk Size (GB) *",-1)),r(e("input",{"onUpdate:modelValue":o[11]||(o[11]=s=>t.formData.disk_size=s),type:"number",min:"10",class:"form-control",required:""},null,512),[[v,t.formData.disk_size,void 0,{number:!0}]])])])]),e("div",je,[e("h4",{class:"section-title collapsible-header",onClick:o[12]||(o[12]=s=>t.showAdvancedHardware=!t.showAdvancedHardware)},[e("span",Je,u(t.showAdvancedHardware?"▼":"▶"),1),o[94]||(o[94]=c(" Advanced Hardware Options ",-1))]),r(e("div",Ze,[e("div",$e,[e("div",os,[o[96]||(o[96]=e("label",{class:"form-label"},"CPU Type",-1)),r(e("select",{"onUpdate:modelValue":o[13]||(o[13]=s=>t.formData.cpu_type=s),class:"form-control"},[...o[95]||(o[95]=[h('',23)])],512),[[_,t.formData.cpu_type]])]),e("div",es,[o[98]||(o[98]=e("label",{class:"form-label"},"BIOS Type",-1)),r(e("select",{"onUpdate:modelValue":o[14]||(o[14]=s=>t.formData.bios_type=s),class:"form-control"},[...o[97]||(o[97]=[e("option",{value:"seabios"},"SeaBIOS (Legacy)",-1),e("option",{value:"ovmf"},"OVMF (UEFI)",-1)])],512),[[_,t.formData.bios_type]])]),e("div",ss,[o[100]||(o[100]=e("label",{class:"form-label"},"VGA Type",-1)),r(e("select",{"onUpdate:modelValue":o[15]||(o[15]=s=>t.formData.vga_type=s),class:"form-control"},[...o[99]||(o[99]=[h('',5)])],512),[[_,t.formData.vga_type]])])]),e("div",ts,[e("div",as,[o[102]||(o[102]=e("label",{class:"form-label"},"Machine Type",-1)),r(e("select",{"onUpdate:modelValue":o[16]||(o[16]=s=>t.formData.machine_type=s),class:"form-control"},[...o[101]||(o[101]=[e("option",{value:"pc"},"PC (i440FX)",-1),e("option",{value:"q35"},"Q35 (PCIe)",-1)])],512),[[_,t.formData.machine_type]])]),e("div",ls,[o[104]||(o[104]=e("label",{class:"form-label"},"Boot Order",-1)),r(e("select",{"onUpdate:modelValue":o[17]||(o[17]=s=>t.formData.boot_order=s),class:"form-control"},[...o[103]||(o[103]=[h('',6)])],512),[[_,t.formData.boot_order]])]),e("div",rs,[e("label",ns,[r(e("input",{"onUpdate:modelValue":o[18]||(o[18]=s=>t.formData.numa_enabled=s),type:"checkbox",style:{"margin-right":"8px"}},null,512),[[D,t.formData.numa_enabled]]),o[105]||(o[105]=c(" Enable NUMA ",-1))]),o[106]||(o[106]=e("div",{class:"form-text"},"Enable Non-Uniform Memory Access",-1))])]),e("div",is,[o[107]||(o[107]=e("label",{class:"form-label"},"CPU Flags (optional)",-1)),r(e("input",{"onUpdate:modelValue":o[19]||(o[19]=s=>t.formData.cpu_flags=s),type:"text",class:"form-control",placeholder:"e.g., +aes,+ssse3"},null,512),[[v,t.formData.cpu_flags]]),o[108]||(o[108]=e("small",{class:"form-help"},"Additional CPU flags (comma-separated)",-1))]),e("div",ds,[e("div",us,[o[109]||(o[109]=e("label",{class:"form-label"},"CPU Limit (optional)",-1)),r(e("input",{"onUpdate:modelValue":o[20]||(o[20]=s=>t.formData.cpu_limit=s),type:"number",min:"0",max:"128",class:"form-control",placeholder:"0 = unlimited"},null,512),[[v,t.formData.cpu_limit,void 0,{number:!0}]]),o[110]||(o[110]=e("small",{class:"form-help"},"Limit CPU usage (0-128, 0 = unlimited)",-1))]),e("div",ms,[o[111]||(o[111]=e("label",{class:"form-label"},"CPU Units",-1)),r(e("input",{"onUpdate:modelValue":o[21]||(o[21]=s=>t.formData.cpu_units=s),type:"number",min:"8",max:"500000",class:"form-control"},null,512),[[v,t.formData.cpu_units,void 0,{number:!0}]]),o[112]||(o[112]=e("small",{class:"form-help"},"CPU scheduler weight (default: 1024)",-1))])]),e("div",vs,[e("div",ps,[o[113]||(o[113]=e("label",{class:"form-label"},"Memory Balloon (MB, optional)",-1)),r(e("input",{"onUpdate:modelValue":o[22]||(o[22]=s=>t.formData.balloon=s),type:"number",min:"0",class:"form-control",placeholder:"0 = disabled"},null,512),[[v,t.formData.balloon,void 0,{number:!0}]]),o[114]||(o[114]=e("small",{class:"form-help"},"Dynamic memory allocation (0 = disabled)",-1))]),e("div",gs,[o[115]||(o[115]=e("label",{class:"form-label"},"Memory Shares (optional)",-1)),r(e("input",{"onUpdate:modelValue":o[23]||(o[23]=s=>t.formData.shares=s),type:"number",min:"0",max:"50000",class:"form-control",placeholder:"Default"},null,512),[[v,t.formData.shares,void 0,{number:!0}]]),o[116]||(o[116]=e("small",{class:"form-help"},"Memory scheduler weight",-1))])]),e("div",cs,[e("div",fs,[o[118]||(o[118]=e("label",{class:"form-label"},"SCSI Controller",-1)),r(e("select",{"onUpdate:modelValue":o[24]||(o[24]=s=>t.formData.scsihw=s),class:"form-control"},[...o[117]||(o[117]=[h('',6)])],512),[[_,t.formData.scsihw]])]),e("div",bs,[e("label",ys,[r(e("input",{type:"checkbox","onUpdate:modelValue":o[25]||(o[25]=s=>t.formData.tablet=s),style:{"margin-right":"8px"}},null,512),[[D,t.formData.tablet]]),o[119]||(o[119]=c(" Enable Tablet Pointer Device ",-1))]),o[120]||(o[120]=e("div",{class:"form-text"},"Improves mouse tracking in VNC/SPICE",-1))])])],512),[[B,t.showAdvancedHardware]])]),e("div",_s,[e("h4",{class:"section-title collapsible-header",onClick:o[26]||(o[26]=s=>t.showBootStartup=!t.showBootStartup)},[e("span",ws,u(t.showBootStartup?"▼":"▶"),1),o[121]||(o[121]=c(" Boot & Startup Options ",-1))]),r(e("div",ks,[e("div",Ds,[e("label",Ss,[r(e("input",{type:"checkbox","onUpdate:modelValue":o[27]||(o[27]=s=>t.formData.onboot=s),style:{"margin-right":"8px"}},null,512),[[D,t.formData.onboot]]),o[122]||(o[122]=c(" Start VM at boot ",-1))]),o[123]||(o[123]=e("div",{class:"form-text"},"Automatically start this VM when the Proxmox host boots",-1))]),e("div",xs,[e("div",Cs,[o[124]||(o[124]=e("label",{class:"form-label"},"Startup Order (optional)",-1)),r(e("input",{"onUpdate:modelValue":o[28]||(o[28]=s=>t.formData.startup_order=s),type:"number",min:"0",class:"form-control",placeholder:"Default"},null,512),[[v,t.formData.startup_order,void 0,{number:!0}]]),o[125]||(o[125]=e("small",{class:"form-help"},"Boot order (lower starts first)",-1))]),e("div",Vs,[o[126]||(o[126]=e("label",{class:"form-label"},"Startup Delay (seconds)",-1)),r(e("input",{"onUpdate:modelValue":o[29]||(o[29]=s=>t.formData.startup_up=s),type:"number",min:"0",class:"form-control",placeholder:"0"},null,512),[[v,t.formData.startup_up,void 0,{number:!0}]]),o[127]||(o[127]=e("small",{class:"form-help"},"Delay before starting",-1))]),e("div",Ms,[o[128]||(o[128]=e("label",{class:"form-label"},"Shutdown Timeout (seconds)",-1)),r(e("input",{"onUpdate:modelValue":o[30]||(o[30]=s=>t.formData.startup_down=s),type:"number",min:"0",class:"form-control",placeholder:"60"},null,512),[[v,t.formData.startup_down,void 0,{number:!0}]]),o[129]||(o[129]=e("small",{class:"form-help"},"Wait time before force stop",-1))])])],512),[[B,t.showBootStartup]])]),e("div",Ps,[e("h4",{class:"section-title collapsible-header",onClick:o[31]||(o[31]=s=>t.showSystemOptions=!t.showSystemOptions)},[e("span",Us,u(t.showSystemOptions?"▼":"▶"),1),o[130]||(o[130]=c(" System Options ",-1))]),r(e("div",Is,[e("div",hs,[e("div",Bs,[o[131]||(o[131]=e("label",{class:"form-label"},"Hotplug Options (optional)",-1)),r(e("input",{"onUpdate:modelValue":o[32]||(o[32]=s=>t.formData.hotplug=s),type:"text",class:"form-control",placeholder:"disk,network,usb,memory,cpu"},null,512),[[v,t.formData.hotplug]]),o[132]||(o[132]=e("small",{class:"form-help"},"Comma-separated list of hotplug devices",-1))]),e("div",As,[e("label",Ns,[r(e("input",{type:"checkbox","onUpdate:modelValue":o[33]||(o[33]=s=>t.formData.protection=s),style:{"margin-right":"8px"}},null,512),[[D,t.formData.protection]]),o[133]||(o[133]=c(" Protection (prevent deletion) ",-1))]),o[134]||(o[134]=e("div",{class:"form-text"},"Protect VM from accidental removal",-1))])]),e("div",Os,[e("div",Es,[e("label",Ls,[r(e("input",{type:"checkbox","onUpdate:modelValue":o[34]||(o[34]=s=>t.formData.kvm=s),style:{"margin-right":"8px"}},null,512),[[D,t.formData.kvm]]),o[135]||(o[135]=c(" Enable KVM Hardware Virtualization ",-1))]),o[136]||(o[136]=e("div",{class:"form-text"},"Use hardware virtualization",-1))]),e("div",Hs,[e("label",Fs,[r(e("input",{type:"checkbox","onUpdate:modelValue":o[35]||(o[35]=s=>t.formData.acpi=s),style:{"margin-right":"8px"}},null,512),[[D,t.formData.acpi]]),o[137]||(o[137]=c(" Enable ACPI ",-1))]),o[138]||(o[138]=e("div",{class:"form-text"},"Advanced power management",-1))]),e("div",Ts,[e("label",qs,[r(e("input",{type:"checkbox","onUpdate:modelValue":o[36]||(o[36]=s=>t.formData.agent_enabled=s),style:{"margin-right":"8px"}},null,512),[[D,t.formData.agent_enabled]]),o[139]||(o[139]=c(" Enable QEMU Guest Agent ",-1))]),o[140]||(o[140]=e("div",{class:"form-text"},"Better VM management",-1))])])],512),[[B,t.showSystemOptions]])]),e("div",zs,[e("h4",{class:"section-title collapsible-header",onClick:o[37]||(o[37]=s=>t.showMetadata=!t.showMetadata)},[e("span",Gs,u(t.showMetadata?"▼":"▶"),1),o[141]||(o[141]=c(" VM Metadata ",-1))]),r(e("div",Rs,[e("div",Ws,[o[142]||(o[142]=e("label",{class:"form-label"},"Description (optional)",-1)),r(e("textarea",{"onUpdate:modelValue":o[38]||(o[38]=s=>t.formData.description=s),class:"form-control",rows:"2",placeholder:"VM description or notes..."},null,512),[[v,t.formData.description]])]),e("div",Xs,[o[143]||(o[143]=e("label",{class:"form-label"},"Tags (optional)",-1)),r(e("input",{"onUpdate:modelValue":o[39]||(o[39]=s=>t.formData.tags=s),type:"text",class:"form-control",placeholder:"production;web-server;app"},null,512),[[v,t.formData.tags]]),o[144]||(o[144]=e("small",{class:"form-help"},"Semicolon-separated tags",-1))])],512),[[B,t.showMetadata]])]),e("div",Ys,[o[150]||(o[150]=e("h4",{class:"section-title"},"Network Configuration",-1)),e("div",Qs,[e("label",Ks,[r(e("input",{"onUpdate:modelValue":o[40]||(o[40]=s=>t.useDHCP=s),type:"checkbox"},null,512),[[D,t.useDHCP]]),o[145]||(o[145]=c(" Use DHCP (automatic IP assignment) ",-1))])]),t.useDHCP?g("",!0):(n(),i("div",js,[e("div",Js,[o[146]||(o[146]=e("label",{class:"form-label"},"IP Address",-1)),r(e("input",{"onUpdate:modelValue":o[41]||(o[41]=s=>t.formData.ip_address=s),type:"text",class:"form-control",placeholder:"192.168.1.100"},null,512),[[v,t.formData.ip_address]])]),e("div",Zs,[o[147]||(o[147]=e("label",{class:"form-label"},"Netmask (CIDR)",-1)),r(e("input",{"onUpdate:modelValue":o[42]||(o[42]=s=>t.formData.netmask=s),type:"text",class:"form-control",placeholder:"24"},null,512),[[v,t.formData.netmask]])]),e("div",$s,[o[148]||(o[148]=e("label",{class:"form-label"},"Gateway",-1)),r(e("input",{"onUpdate:modelValue":o[43]||(o[43]=s=>t.formData.gateway=s),type:"text",class:"form-control",placeholder:"192.168.1.1"},null,512),[[v,t.formData.gateway]])]),e("div",ot,[o[149]||(o[149]=e("label",{class:"form-label"},"DNS Servers",-1)),r(e("input",{"onUpdate:modelValue":o[44]||(o[44]=s=>t.formData.dns_servers=s),type:"text",class:"form-control",placeholder:"8.8.8.8,8.8.4.4"},null,512),[[v,t.formData.dns_servers]])])]))]),e("div",et,[o[154]||(o[154]=e("h4",{class:"section-title"},"Credentials",-1)),e("div",st,[e("div",tt,[o[151]||(o[151]=e("label",{class:"form-label"},"Username *",-1)),r(e("input",{"onUpdate:modelValue":o[45]||(o[45]=s=>t.formData.username=s),type:"text",class:"form-control",required:""},null,512),[[v,t.formData.username]])]),e("div",at,[o[152]||(o[152]=e("label",{class:"form-label"},"Password *",-1)),r(e("input",{"onUpdate:modelValue":o[46]||(o[46]=s=>t.formData.password=s),type:"password",class:"form-control",required:""},null,512),[[v,t.formData.password]])])]),e("div",lt,[o[153]||(o[153]=e("label",{class:"form-label"},"SSH Public Key (optional)",-1)),r(e("textarea",{"onUpdate:modelValue":o[47]||(o[47]=s=>t.formData.ssh_key=s),class:"form-control",rows:"3",placeholder:"ssh-rsa AAAA..."},null,512),[[v,t.formData.ssh_key]])])]),e("div",rt,[e("button",{type:"submit",class:"btn btn-primary",disabled:t.creating},u(t.creating?"Creating VM...":"Create Virtual Machine"),9,nt),Do(x,{to:"/vms",class:"btn btn-outline"},{default:So(()=>[...o[155]||(o[155]=[c(" Cancel ",-1)])]),_:1})])],32)])]),(n(),wo(xo,{to:"body"},[r(e("div",it,[e("div",dt,[e("div",ut,[e("h3",null,u(t.progressData.status==="error"?"Deployment Failed":"Deploying VM"),1)]),e("div",mt,[e("div",vt,[t.progressData.status==="creating"?(n(),i("div",pt,[...o[157]||(o[157]=[e("div",{class:"spinner"},null,-1)])])):t.progressData.status==="running"?(n(),i("div",gt,"✓")):t.progressData.status==="error"?(n(),i("div",ct,"✕")):g("",!0),e("h4",ft,u(t.progressData.name),1),e("div",bt,[e("div",yt,u(t.progressData.status_message||"Initializing deployment..."),1)]),t.uploadProgress>0&&t.uploadProgress<100?(n(),i("div",_t,[e("div",wt,[e("div",{class:"progress-bar-fill",style:G({width:t.uploadProgress+"%"})},[e("span",kt,u(t.uploadProgress)+"%",1)],4)])])):g("",!0),t.progressData.vmid?(n(),i("div",Dt," VM ID: "+u(t.progressData.vmid),1)):g("",!0),t.progressData.error_message?(n(),i("div",St,[o[158]||(o[158]=e("strong",null,"Error:",-1)),c(" "+u(t.progressData.error_message),1)])):g("",!0)])]),e("div",xt,[t.progressData.status==="running"||t.progressData.status==="error"?(n(),i("button",{key:0,onClick:o[49]||(o[49]=(...s)=>t.closeProgressModal&&t.closeProgressModal(...s)),class:"btn btn-primary"},u(t.progressData.status==="running"?"Go to VMs":"Close"),1)):g("",!0)])])],512),[[B,t.showProgressModal]])]))],64)}const Pt=yo(Uo,[["render",Ct],["__scopeId","data-v-d016d938"]]);export{Pt as default}; diff --git a/src/frontend/dist/assets/Dashboard-D-lJZ8zJ.js b/src/frontend/dist/assets/Dashboard-D-lJZ8zJ.js new file mode 100644 index 0000000..c44afe3 --- /dev/null +++ b/src/frontend/dist/assets/Dashboard-D-lJZ8zJ.js @@ -0,0 +1 @@ +import{_ as f,g as y,c as v,o as p,a as s,h as l,i as r,t as e,d as k,n as d,r as m,j as c,k as h,l as g}from"./index-DpP9cWht.js";const x={name:"Dashboard",setup(){const n=m({total_vms:0,running_vms:0,stopped_vms:0,paused_vms:0,datacenters:0,total_nodes:0}),t=m(null),i=c(()=>t.value?Math.round(t.value.used_cpu_cores/t.value.total_cpu_cores*100):0),a=c(()=>t.value?Math.round(t.value.used_memory_gb/t.value.total_memory_gb*100):0),u=c(()=>t.value?Math.round(t.value.used_disk_gb/t.value.total_disk_gb*100):0),_=async()=>{try{const[o,b]=await Promise.all([g.dashboard.getStats(),g.dashboard.getResources()]);n.value=o.data,t.value=b.data}catch(o){console.error("Failed to fetch dashboard data:",o)}};return h(()=>{_()}),{stats:n,resources:t,cpuPercentage:i,memoryPercentage:a,diskPercentage:u}}},w={class:"dashboard"},P={class:"stats-row mb-2"},M={class:"stat-info"},B={class:"stat-value"},C={class:"stat-info"},D={class:"stat-value"},S={class:"stat-info"},N={class:"stat-value"},R={class:"stat-info"},V={class:"stat-value"},G={class:"stat-info"},I={class:"stat-value"},U={class:"grid grid-cols-2 gap-2"},A={class:"card"},O={key:0,class:"resources"},j={class:"resource-item"},q={class:"resource-bar"},z={class:"resource-value"},E={class:"resource-item"},F={class:"resource-bar"},H={class:"resource-value"},Q={class:"resource-item"},J={class:"resource-bar"},K={class:"resource-value"},L={class:"card"},T={class:"quick-actions"};function W(n,t,i,a,u,_){const o=y("router-link");return p(),v("div",w,[s("div",P,[l(o,{to:"/vms",class:"stat-card card"},{default:r(()=>[t[1]||(t[1]=s("div",{class:"stat-icon"},"🖥️",-1)),s("div",M,[s("p",B,e(a.stats.total_vms),1),t[0]||(t[0]=s("p",{class:"stat-label"},"VMs",-1))])]),_:1}),l(o,{to:"/vms?status=running",class:"stat-card card"},{default:r(()=>[t[3]||(t[3]=s("div",{class:"stat-icon success"},"▶️",-1)),s("div",C,[s("p",D,e(a.stats.running_vms),1),t[2]||(t[2]=s("p",{class:"stat-label"},"Running",-1))])]),_:1}),l(o,{to:"/vms?status=stopped",class:"stat-card card"},{default:r(()=>[t[5]||(t[5]=s("div",{class:"stat-icon danger"},"⏹️",-1)),s("div",S,[s("p",N,e(a.stats.stopped_vms),1),t[4]||(t[4]=s("p",{class:"stat-label"},"Stopped",-1))])]),_:1}),l(o,{to:"/proxmox",class:"stat-card card"},{default:r(()=>[t[7]||(t[7]=s("div",{class:"stat-icon"},"🏢",-1)),s("div",R,[s("p",V,e(a.stats.datacenters),1),t[6]||(t[6]=s("p",{class:"stat-label"},"Datacenters",-1))])]),_:1}),l(o,{to:"/proxmox",class:"stat-card card"},{default:r(()=>[t[9]||(t[9]=s("div",{class:"stat-icon"},"🖧",-1)),s("div",G,[s("p",I,e(a.stats.total_nodes),1),t[8]||(t[8]=s("p",{class:"stat-label"},"Nodes",-1))])]),_:1})]),s("div",U,[s("div",A,[t[13]||(t[13]=s("div",{class:"card-header"},[s("h3",null,"Resource Usage")],-1)),a.resources?(p(),v("div",O,[s("div",j,[t[10]||(t[10]=s("span",{class:"resource-label"},"CPU Cores",-1)),s("div",q,[s("div",{class:"resource-fill",style:d({width:a.cpuPercentage+"%"})},null,4)]),s("span",z,e(a.resources.used_cpu_cores)+" / "+e(a.resources.total_cpu_cores),1)]),s("div",E,[t[11]||(t[11]=s("span",{class:"resource-label"},"Memory",-1)),s("div",F,[s("div",{class:"resource-fill",style:d({width:a.memoryPercentage+"%"})},null,4)]),s("span",H,e(a.resources.used_memory_gb)+"GB / "+e(a.resources.total_memory_gb)+"GB ",1)]),s("div",Q,[t[12]||(t[12]=s("span",{class:"resource-label"},"Disk",-1)),s("div",J,[s("div",{class:"resource-fill",style:d({width:a.diskPercentage+"%"})},null,4)]),s("span",K,e(a.resources.used_disk_gb)+"GB / "+e(a.resources.total_disk_gb)+"GB ",1)])])):k("",!0)]),s("div",L,[t[17]||(t[17]=s("div",{class:"card-header"},[s("h3",null,"Quick Actions")],-1)),s("div",T,[l(o,{to:"/vms/create",class:"action-button"},{default:r(()=>[...t[14]||(t[14]=[s("span",{class:"action-icon"},"➕",-1),s("div",null,[s("p",{class:"action-title"},"Deploy New VM"),s("p",{class:"action-desc"},"Create and deploy a new virtual machine")],-1)])]),_:1}),l(o,{to:"/proxmox",class:"action-button"},{default:r(()=>[...t[15]||(t[15]=[s("span",{class:"action-icon"},"🌐",-1),s("div",null,[s("p",{class:"action-title"},"Manage Hosts"),s("p",{class:"action-desc"},"Configure Proxmox hosts")],-1)])]),_:1}),l(o,{to:"/isos",class:"action-button"},{default:r(()=>[...t[16]||(t[16]=[s("span",{class:"action-icon"},"💿",-1),s("div",null,[s("p",{class:"action-title"},"Upload ISO"),s("p",{class:"action-desc"},"Add new ISO images")],-1)])]),_:1})])])])])}const Y=f(x,[["render",W],["__scopeId","data-v-9e9a0d16"]]);export{Y as default}; diff --git a/src/frontend/dist/assets/Dashboard-FZCEL6lL.css b/src/frontend/dist/assets/Dashboard-FZCEL6lL.css new file mode 100644 index 0000000..2d74ae5 --- /dev/null +++ b/src/frontend/dist/assets/Dashboard-FZCEL6lL.css @@ -0,0 +1 @@ +.stats-row[data-v-9e9a0d16]{display:flex;gap:.75rem;justify-content:space-between}.stat-card[data-v-9e9a0d16]{display:flex;align-items:center;gap:.5rem;padding:.5rem .75rem!important;flex:1;min-width:0;cursor:pointer;transition:all .2s;text-decoration:none}.stat-card[data-v-9e9a0d16]:hover{transform:translateY(-2px);box-shadow:0 4px 8px #0000001a;border-color:var(--primary-color)}.stat-card[data-v-9e9a0d16]:active{transform:translateY(0)}.stat-icon[data-v-9e9a0d16]{font-size:1.5rem;opacity:.8;flex-shrink:0}.stat-icon.success[data-v-9e9a0d16]{color:var(--secondary-color)}.stat-icon.danger[data-v-9e9a0d16]{color:var(--danger-color)}.stat-info[data-v-9e9a0d16]{flex:1;min-width:0}.stat-value[data-v-9e9a0d16]{font-size:1.25rem;font-weight:700;color:var(--text-primary);margin:0;white-space:nowrap}.stat-label[data-v-9e9a0d16]{font-size:.7rem;color:var(--text-secondary);margin:0;white-space:nowrap}.resources[data-v-9e9a0d16]{display:flex;flex-direction:column;gap:.75rem}.resource-item[data-v-9e9a0d16]{display:flex;flex-direction:column;gap:.25rem}.resource-label[data-v-9e9a0d16]{font-weight:500;color:var(--text-secondary);font-size:.75rem}.resource-bar[data-v-9e9a0d16]{height:.375rem;background-color:var(--background);border-radius:9999px;overflow:hidden}.resource-fill[data-v-9e9a0d16]{height:100%;background:linear-gradient(90deg,var(--primary-color),var(--secondary-color));transition:width .3s ease}.resource-value[data-v-9e9a0d16]{font-size:.7rem;color:var(--text-secondary)}.quick-actions[data-v-9e9a0d16]{display:flex;flex-direction:column;gap:.5rem}.action-button[data-v-9e9a0d16]{display:flex;align-items:center;gap:.5rem;padding:.5rem;border:1px solid var(--border-color);border-radius:.375rem;text-decoration:none;transition:all .2s}.action-button[data-v-9e9a0d16]:hover{background-color:var(--background);border-color:var(--primary-color)}.action-icon[data-v-9e9a0d16]{font-size:1.25rem}.action-title[data-v-9e9a0d16]{font-weight:600;color:var(--text-primary);margin:0;font-size:.85rem}.action-desc[data-v-9e9a0d16]{font-size:.7rem;color:var(--text-secondary);margin:0} diff --git a/src/frontend/dist/assets/Documentation-BwVxcGBy.js b/src/frontend/dist/assets/Documentation-BwVxcGBy.js new file mode 100644 index 0000000..8b54310 --- /dev/null +++ b/src/frontend/dist/assets/Documentation-BwVxcGBy.js @@ -0,0 +1,59 @@ +var Pe=Object.defineProperty;var Ie=(n,e,t)=>e in n?Pe(n,e,{enumerable:!0,configurable:!0,writable:!0,value:t}):n[e]=t;var m=(n,e,t)=>Ie(n,typeof e!="symbol"?e+"":e,t);import{_ as Le,g as Ee,c as T,o as A,a,d as ue,B as ce,w as z,h as D,i as P,b as w,t as pe,x as Be,r as I,j as qe,l as de}from"./index-DpP9cWht.js";function _(){return{async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null}}var E=_();function me(n){E=n}var G={exec:()=>null};function f(n,e=""){let t=typeof n=="string"?n:n.source,s={replace:(r,i)=>{let u=typeof i=="string"?i:i.source;return u=u.replace(v.caret,"$1"),t=t.replace(r,u),s},getRegex:()=>new RegExp(t,e)};return s}var Me=(()=>{try{return!!new RegExp("(?<=1)(?/,blockquoteSetextReplace:/\n {0,3}((?:=+|-+) *)(?=\n|$)/g,blockquoteSetextReplace2:/^ {0,3}>[ \t]?/gm,listReplaceTabs:/^\t+/,listReplaceNesting:/^ {1,4}(?=( {4})*[^ ])/g,listIsTask:/^\[[ xX]\] /,listReplaceTask:/^\[[ xX]\] +/,listTaskCheckbox:/\[[ xX]\]/,anyLine:/\n.*\n/,hrefBrackets:/^<(.*)>$/,tableDelimiter:/[:|]/,tableAlignChars:/^\||\| *$/g,tableRowBlankLine:/\n[ \t]*$/,tableAlignRight:/^ *-+: *$/,tableAlignCenter:/^ *:-+: *$/,tableAlignLeft:/^ *:-+ *$/,startATag:/^/i,startPreScriptTag:/^<(pre|code|kbd|script)(\s|>)/i,endPreScriptTag:/^<\/(pre|code|kbd|script)(\s|>)/i,startAngleBracket:/^$/,pedanticHrefTitle:/^([^'"]*[^\s])\s+(['"])(.*)\2/,unicodeAlphaNumeric:/[\p{L}\p{N}]/u,escapeTest:/[&<>"']/,escapeReplace:/[&<>"']/g,escapeTestNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/,escapeReplaceNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/g,unescapeTest:/&(#(?:\d+)|(?:#x[0-9A-Fa-f]+)|(?:\w+));?/ig,caret:/(^|[^\[])\^/g,percentDecode:/%25/g,findPipe:/\|/g,splitPipe:/ \|/,slashPipe:/\\\|/g,carriageReturn:/\r\n|\r/g,spaceLine:/^ +$/gm,notSpaceStart:/^\S*/,endingNewline:/\n$/,listItemRegex:n=>new RegExp(`^( {0,3}${n})((?:[ ][^\\n]*)?(?:\\n|$))`),nextBulletRegex:n=>new RegExp(`^ {0,${Math.min(3,n-1)}}(?:[*+-]|\\d{1,9}[.)])((?:[ ][^\\n]*)?(?:\\n|$))`),hrRegex:n=>new RegExp(`^ {0,${Math.min(3,n-1)}}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$)`),fencesBeginRegex:n=>new RegExp(`^ {0,${Math.min(3,n-1)}}(?:\`\`\`|~~~)`),headingBeginRegex:n=>new RegExp(`^ {0,${Math.min(3,n-1)}}#`),htmlBeginRegex:n=>new RegExp(`^ {0,${Math.min(3,n-1)}}<(?:[a-z].*>|!--)`,"i")},Fe=/^(?:[ \t]*(?:\n|$))+/,Ge=/^((?: {4}| {0,3}\t)[^\n]+(?:\n(?:[ \t]*(?:\n|$))*)?)+/,Ze=/^ {0,3}(`{3,}(?=[^`\n]*(?:\n|$))|~{3,})([^\n]*)(?:\n|$)(?:|([\s\S]*?)(?:\n|$))(?: {0,3}\1[~`]* *(?=\n|$)|$)/,Z=/^ {0,3}((?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/,Qe=/^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/,ee=/(?:[*+-]|\d{1,9}[.)])/,we=/^(?!bull |blockCode|fences|blockquote|heading|html|table)((?:.|\n(?!\s*?\n|bull |blockCode|fences|blockquote|heading|html|table))+?)\n {0,3}(=+|-+) *(?:\n+|$)/,ve=f(we).replace(/bull/g,ee).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).replace(/\|table/g,"").getRegex(),Oe=f(we).replace(/bull/g,ee).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).replace(/table/g,/ {0,3}\|?(?:[:\- ]*\|)+[\:\- ]*\n/).getRegex(),te=/^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\n)[^\n]+)*)/,Ne=/^[^\n]+/,ne=/(?!\s*\])(?:\\[\s\S]|[^\[\]\\])+/,Ve=f(/^ {0,3}\[(label)\]: *(?:\n[ \t]*)?([^<\s][^\s]*|<.*?>)(?:(?: +(?:\n[ \t]*)?| *\n[ \t]*)(title))? *(?:\n+|$)/).replace("label",ne).replace("title",/(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/).getRegex(),He=f(/^( {0,3}bull)([ \t][^\n]+?)?(?:\n|$)/).replace(/bull/g,ee).getRegex(),j="address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul",se=/|$))/,Ue=f("^ {0,3}(?:<(script|pre|style|textarea)[\\s>][\\s\\S]*?(?:[^\\n]*\\n+|$)|comment[^\\n]*(\\n+|$)|<\\?[\\s\\S]*?(?:\\?>\\n*|$)|\\n*|$)|\\n*|$)|)[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$)|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$)|(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$))","i").replace("comment",se).replace("tag",j).replace("attribute",/ +[a-zA-Z:_][\w.:-]*(?: *= *"[^"\n]*"| *= *'[^'\n]*'| *= *[^\s"'=<>`]+)?/).getRegex(),ye=f(te).replace("hr",Z).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("|table","").replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",j).getRegex(),je=f(/^( {0,3}> ?(paragraph|[^\n]*)(?:\n|$))+/).replace("paragraph",ye).getRegex(),re={blockquote:je,code:Ge,def:Ve,fences:Ze,heading:Qe,hr:Z,html:Ue,lheading:ve,list:He,newline:Fe,paragraph:ye,table:G,text:Ne},he=f("^ *([^\\n ].*)\\n {0,3}((?:\\| *)?:?-+:? *(?:\\| *:?-+:? *)*(?:\\| *)?)(?:\\n((?:(?! *\\n|hr|heading|blockquote|code|fences|list|html).*(?:\\n|$))*)\\n*|$)").replace("hr",Z).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("blockquote"," {0,3}>").replace("code","(?: {4}| {0,3} )[^\\n]").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",j).getRegex(),We={...re,lheading:Oe,table:he,paragraph:f(te).replace("hr",Z).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("table",he).replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",j).getRegex()},Xe={...re,html:f(`^ *(?:comment *(?:\\n|\\s*$)|<(tag)[\\s\\S]+? *(?:\\n{2,}|\\s*$)|\\s]*)*?/?> *(?:\\n{2,}|\\s*$))`).replace("comment",se).replace(/tag/g,"(?!(?:a|em|strong|small|s|cite|q|dfn|abbr|data|time|code|var|samp|kbd|sub|sup|i|b|u|mark|ruby|rt|rp|bdi|bdo|span|br|wbr|ins|del|img)\\b)\\w+(?!:|[^\\w\\s@]*@)\\b").getRegex(),def:/^ *\[([^\]]+)\]: *]+)>?(?: +(["(][^\n]+[")]))? *(?:\n+|$)/,heading:/^(#{1,6})(.*)(?:\n+|$)/,fences:G,lheading:/^(.+?)\n {0,3}(=+|-+) *(?:\n+|$)/,paragraph:f(te).replace("hr",Z).replace("heading",` *#{1,6} *[^ +]`).replace("lheading",ve).replace("|table","").replace("blockquote"," {0,3}>").replace("|fences","").replace("|list","").replace("|html","").replace("|tag","").getRegex()},Ye=/^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/,Je=/^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/,Se=/^( {2,}|\\)\n(?!\s*$)/,Ke=/^(`+|[^`])(?:(?= {2,}\n)|[\s\S]*?(?:(?=[\\`+)[^`]+\k(?!`))*?\]\((?:\\[\s\S]|[^\\\(\)]|\((?:\\[\s\S]|[^\\\(\)])*\))*\)/).replace("precode-",Me?"(?`+)[^`]+\k(?!`)/).replace("html",/<(?! )[^<>]*?>/).getRegex(),Ce=/^(?:\*+(?:((?!\*)punct)|[^\s*]))|^_+(?:((?!_)punct)|([^\s_]))/,st=f(Ce,"u").replace(/punct/g,W).getRegex(),rt=f(Ce,"u").replace(/punct/g,$e).getRegex(),Te="^[^_*]*?__[^_*]*?\\*[^_*]*?(?=__)|[^*]+(?=[^*])|(?!\\*)punct(\\*+)(?=[\\s]|$)|notPunctSpace(\\*+)(?!\\*)(?=punctSpace|$)|(?!\\*)punctSpace(\\*+)(?=notPunctSpace)|[\\s](\\*+)(?!\\*)(?=punct)|(?!\\*)punct(\\*+)(?!\\*)(?=punct)|notPunctSpace(\\*+)(?=notPunctSpace)",lt=f(Te,"gu").replace(/notPunctSpace/g,Re).replace(/punctSpace/g,le).replace(/punct/g,W).getRegex(),it=f(Te,"gu").replace(/notPunctSpace/g,tt).replace(/punctSpace/g,et).replace(/punct/g,$e).getRegex(),at=f("^[^_*]*?\\*\\*[^_*]*?_[^_*]*?(?=\\*\\*)|[^_]+(?=[^_])|(?!_)punct(_+)(?=[\\s]|$)|notPunctSpace(_+)(?!_)(?=punctSpace|$)|(?!_)punctSpace(_+)(?=notPunctSpace)|[\\s](_+)(?!_)(?=punct)|(?!_)punct(_+)(?!_)(?=punct)","gu").replace(/notPunctSpace/g,Re).replace(/punctSpace/g,le).replace(/punct/g,W).getRegex(),ot=f(/\\(punct)/,"gu").replace(/punct/g,W).getRegex(),ut=f(/^<(scheme:[^\s\x00-\x1f<>]*|email)>/).replace("scheme",/[a-zA-Z][a-zA-Z0-9+.-]{1,31}/).replace("email",/[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+(@)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?![-_])/).getRegex(),ct=f(se).replace("(?:-->|$)","-->").getRegex(),pt=f("^comment|^|^<[a-zA-Z][\\w-]*(?:attribute)*?\\s*/?>|^<\\?[\\s\\S]*?\\?>|^|^").replace("comment",ct).replace("attribute",/\s+[a-zA-Z:_][\w.:-]*(?:\s*=\s*"[^"]*"|\s*=\s*'[^']*'|\s*=\s*[^\s"'=<>`]+)?/).getRegex(),V=/(?:\[(?:\\[\s\S]|[^\[\]\\])*\]|\\[\s\S]|`+[^`]*?`+(?!`)|[^\[\]\\`])*?/,dt=f(/^!?\[(label)\]\(\s*(href)(?:(?:[ \t]*(?:\n[ \t]*)?)(title))?\s*\)/).replace("label",V).replace("href",/<(?:\\.|[^\n<>\\])+>|[^ \t\n\x00-\x1f]*/).replace("title",/"(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)/).getRegex(),Ae=f(/^!?\[(label)\]\[(ref)\]/).replace("label",V).replace("ref",ne).getRegex(),ze=f(/^!?\[(ref)\](?:\[\])?/).replace("ref",ne).getRegex(),ht=f("reflink|nolink(?!\\()","g").replace("reflink",Ae).replace("nolink",ze).getRegex(),ge=/[hH][tT][tT][pP][sS]?|[fF][tT][pP]/,ie={_backpedal:G,anyPunctuation:ot,autolink:ut,blockSkip:nt,br:Se,code:Je,del:G,emStrongLDelim:st,emStrongRDelimAst:lt,emStrongRDelimUnd:at,escape:Ye,link:dt,nolink:ze,punctuation:_e,reflink:Ae,reflinkSearch:ht,tag:pt,text:Ke,url:G},gt={...ie,link:f(/^!?\[(label)\]\((.*?)\)/).replace("label",V).getRegex(),reflink:f(/^!?\[(label)\]\s*\[([^\]]*)\]/).replace("label",V).getRegex()},Y={...ie,emStrongRDelimAst:it,emStrongLDelim:rt,url:f(/^((?:protocol):\/\/|www\.)(?:[a-zA-Z0-9\-]+\.?)+[^\s<]*|^email/).replace("protocol",ge).replace("email",/[A-Za-z0-9._+-]+(@)[a-zA-Z0-9-_]+(?:\.[a-zA-Z0-9-_]*[a-zA-Z0-9])+(?![-_])/).getRegex(),_backpedal:/(?:[^?!.,:;*_'"~()&]+|\([^)]*\)|&(?![a-zA-Z0-9]+;$)|[?!.,:;*_'"~)]+(?!$))+/,del:/^(~~?)(?=[^\s~])((?:\\[\s\S]|[^\\])*?(?:\\[\s\S]|[^\s~\\]))\1(?=[^~]|$)/,text:f(/^([`~]+|[^`~])(?:(?= {2,}\n)|(?=[a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-]+@)|[\s\S]*?(?:(?=[\\":">",'"':""","'":"'"},ke=n=>ft[n];function $(n,e){if(e){if(v.escapeTest.test(n))return n.replace(v.escapeReplace,ke)}else if(v.escapeTestNoEncode.test(n))return n.replace(v.escapeReplaceNoEncode,ke);return n}function fe(n){try{n=encodeURI(n).replace(v.percentDecode,"%")}catch{return null}return n}function be(n,e){var i;let t=n.replace(v.findPipe,(u,l,c)=>{let o=!1,d=l;for(;--d>=0&&c[d]==="\\";)o=!o;return o?"|":" |"}),s=t.split(v.splitPipe),r=0;if(s[0].trim()||s.shift(),s.length>0&&!((i=s.at(-1))!=null&&i.trim())&&s.pop(),e)if(s.length>e)s.splice(e);else for(;s.length0?-2:-1}function xe(n,e,t,s,r){let i=e.href,u=e.title||null,l=n[1].replace(r.other.outputLinkReplace,"$1");s.state.inLink=!0;let c={type:n[0].charAt(0)==="!"?"image":"link",raw:t,href:i,title:u,text:l,tokens:s.inlineTokens(l)};return s.state.inLink=!1,c}function xt(n,e,t){let s=n.match(t.other.indentCodeCompensation);if(s===null)return e;let r=s[1];return e.split(` +`).map(i=>{let u=i.match(t.other.beginningSpace);if(u===null)return i;let[l]=u;return l.length>=r.length?i.slice(r.length):i}).join(` +`)}var H=class{constructor(n){m(this,"options");m(this,"rules");m(this,"lexer");this.options=n||E}space(n){let e=this.rules.block.newline.exec(n);if(e&&e[0].length>0)return{type:"space",raw:e[0]}}code(n){let e=this.rules.block.code.exec(n);if(e){let t=e[0].replace(this.rules.other.codeRemoveIndent,"");return{type:"code",raw:e[0],codeBlockStyle:"indented",text:this.options.pedantic?t:M(t,` +`)}}}fences(n){let e=this.rules.block.fences.exec(n);if(e){let t=e[0],s=xt(t,e[3]||"",this.rules);return{type:"code",raw:t,lang:e[2]?e[2].trim().replace(this.rules.inline.anyPunctuation,"$1"):e[2],text:s}}}heading(n){let e=this.rules.block.heading.exec(n);if(e){let t=e[2].trim();if(this.rules.other.endingHash.test(t)){let s=M(t,"#");(this.options.pedantic||!s||this.rules.other.endingSpaceChar.test(s))&&(t=s.trim())}return{type:"heading",raw:e[0],depth:e[1].length,text:t,tokens:this.lexer.inline(t)}}}hr(n){let e=this.rules.block.hr.exec(n);if(e)return{type:"hr",raw:M(e[0],` +`)}}blockquote(n){let e=this.rules.block.blockquote.exec(n);if(e){let t=M(e[0],` +`).split(` +`),s="",r="",i=[];for(;t.length>0;){let u=!1,l=[],c;for(c=0;c1,r={type:"list",raw:"",ordered:s,start:s?+t.slice(0,-1):"",loose:!1,items:[]};t=s?`\\d{1,9}\\${t.slice(-1)}`:`\\${t}`,this.options.pedantic&&(t=s?t:"[*+-]");let i=this.rules.other.listItemRegex(t),u=!1;for(;n;){let c=!1,o="",d="";if(!(e=i.exec(n))||this.rules.block.hr.test(n))break;o=e[0],n=n.substring(o.length);let g=e[2].split(` +`,1)[0].replace(this.rules.other.listReplaceTabs,C=>" ".repeat(3*C.length)),h=n.split(` +`,1)[0],p=!g.trim(),b=0;if(this.options.pedantic?(b=2,d=g.trimStart()):p?b=e[1].length+1:(b=e[2].search(this.rules.other.nonSpaceChar),b=b>4?1:b,d=g.slice(b),b+=e[1].length),p&&this.rules.other.blankLine.test(h)&&(o+=h+` +`,n=n.substring(h.length+1),c=!0),!c){let C=this.rules.other.nextBulletRegex(b),R=this.rules.other.hrRegex(b),Q=this.rules.other.fencesBeginRegex(b),oe=this.rules.other.headingBeginRegex(b),De=this.rules.other.htmlBeginRegex(b);for(;n;){let X=n.split(` +`,1)[0],B;if(h=X,this.options.pedantic?(h=h.replace(this.rules.other.listReplaceNesting," "),B=h):B=h.replace(this.rules.other.tabCharGlobal," "),Q.test(h)||oe.test(h)||De.test(h)||C.test(h)||R.test(h))break;if(B.search(this.rules.other.nonSpaceChar)>=b||!h.trim())d+=` +`+B.slice(b);else{if(p||g.replace(this.rules.other.tabCharGlobal," ").search(this.rules.other.nonSpaceChar)>=4||Q.test(g)||oe.test(g)||R.test(g))break;d+=` +`+h}!p&&!h.trim()&&(p=!0),o+=X+` +`,n=n.substring(X.length+1),g=B.slice(b)}}r.loose||(u?r.loose=!0:this.rules.other.doubleBlankLine.test(o)&&(u=!0));let k=null;this.options.gfm&&(k=this.rules.other.listIsTask.exec(d),k&&(d=d.replace(this.rules.other.listReplaceTask,""))),r.items.push({type:"list_item",raw:o,task:!!k,loose:!1,text:d,tokens:[]}),r.raw+=o}let l=r.items.at(-1);if(l)l.raw=l.raw.trimEnd(),l.text=l.text.trimEnd();else return;r.raw=r.raw.trimEnd();for(let c of r.items){if(this.lexer.state.top=!1,c.tokens=this.lexer.blockTokens(c.text,[]),c.task){let o=this.rules.other.listTaskCheckbox.exec(c.raw);if(o){let d={type:"checkbox",raw:o[0]+" ",checked:o[0]!=="[ ]"};c.checked=d.checked,r.loose?c.tokens[0]&&["paragraph","text"].includes(c.tokens[0].type)&&"tokens"in c.tokens[0]&&c.tokens[0].tokens?(c.tokens[0].raw=d.raw+c.tokens[0].raw,c.tokens[0].text=d.raw+c.tokens[0].text,c.tokens[0].tokens.unshift(d)):c.tokens.unshift({type:"paragraph",raw:d.raw,text:d.raw,tokens:[d]}):c.tokens.unshift(d)}}if(!r.loose){let o=c.tokens.filter(g=>g.type==="space"),d=o.length>0&&o.some(g=>this.rules.other.anyLine.test(g.raw));r.loose=d}}if(r.loose)for(let c of r.items){c.loose=!0;for(let o of c.tokens)o.type==="text"&&(o.type="paragraph")}return r}}html(n){let e=this.rules.block.html.exec(n);if(e)return{type:"html",block:!0,raw:e[0],pre:e[1]==="pre"||e[1]==="script"||e[1]==="style",text:e[0]}}def(n){let e=this.rules.block.def.exec(n);if(e){let t=e[1].toLowerCase().replace(this.rules.other.multipleSpaceGlobal," "),s=e[2]?e[2].replace(this.rules.other.hrefBrackets,"$1").replace(this.rules.inline.anyPunctuation,"$1"):"",r=e[3]?e[3].substring(1,e[3].length-1).replace(this.rules.inline.anyPunctuation,"$1"):e[3];return{type:"def",tag:t,raw:e[0],href:s,title:r}}}table(n){var u;let e=this.rules.block.table.exec(n);if(!e||!this.rules.other.tableDelimiter.test(e[2]))return;let t=be(e[1]),s=e[2].replace(this.rules.other.tableAlignChars,"").split("|"),r=(u=e[3])!=null&&u.trim()?e[3].replace(this.rules.other.tableRowBlankLine,"").split(` +`):[],i={type:"table",raw:e[0],header:[],align:[],rows:[]};if(t.length===s.length){for(let l of s)this.rules.other.tableAlignRight.test(l)?i.align.push("right"):this.rules.other.tableAlignCenter.test(l)?i.align.push("center"):this.rules.other.tableAlignLeft.test(l)?i.align.push("left"):i.align.push(null);for(let l=0;l({text:c,tokens:this.lexer.inline(c),header:!1,align:i.align[o]})));return i}}lheading(n){let e=this.rules.block.lheading.exec(n);if(e)return{type:"heading",raw:e[0],depth:e[2].charAt(0)==="="?1:2,text:e[1],tokens:this.lexer.inline(e[1])}}paragraph(n){let e=this.rules.block.paragraph.exec(n);if(e){let t=e[1].charAt(e[1].length-1)===` +`?e[1].slice(0,-1):e[1];return{type:"paragraph",raw:e[0],text:t,tokens:this.lexer.inline(t)}}}text(n){let e=this.rules.block.text.exec(n);if(e)return{type:"text",raw:e[0],text:e[0],tokens:this.lexer.inline(e[0])}}escape(n){let e=this.rules.inline.escape.exec(n);if(e)return{type:"escape",raw:e[0],text:e[1]}}tag(n){let e=this.rules.inline.tag.exec(n);if(e)return!this.lexer.state.inLink&&this.rules.other.startATag.test(e[0])?this.lexer.state.inLink=!0:this.lexer.state.inLink&&this.rules.other.endATag.test(e[0])&&(this.lexer.state.inLink=!1),!this.lexer.state.inRawBlock&&this.rules.other.startPreScriptTag.test(e[0])?this.lexer.state.inRawBlock=!0:this.lexer.state.inRawBlock&&this.rules.other.endPreScriptTag.test(e[0])&&(this.lexer.state.inRawBlock=!1),{type:"html",raw:e[0],inLink:this.lexer.state.inLink,inRawBlock:this.lexer.state.inRawBlock,block:!1,text:e[0]}}link(n){let e=this.rules.inline.link.exec(n);if(e){let t=e[2].trim();if(!this.options.pedantic&&this.rules.other.startAngleBracket.test(t)){if(!this.rules.other.endAngleBracket.test(t))return;let i=M(t.slice(0,-1),"\\");if((t.length-i.length)%2===0)return}else{let i=bt(e[2],"()");if(i===-2)return;if(i>-1){let u=(e[0].indexOf("!")===0?5:4)+e[1].length+i;e[2]=e[2].substring(0,i),e[0]=e[0].substring(0,u).trim(),e[3]=""}}let s=e[2],r="";if(this.options.pedantic){let i=this.rules.other.pedanticHrefTitle.exec(s);i&&(s=i[1],r=i[3])}else r=e[3]?e[3].slice(1,-1):"";return s=s.trim(),this.rules.other.startAngleBracket.test(s)&&(this.options.pedantic&&!this.rules.other.endAngleBracket.test(t)?s=s.slice(1):s=s.slice(1,-1)),xe(e,{href:s&&s.replace(this.rules.inline.anyPunctuation,"$1"),title:r&&r.replace(this.rules.inline.anyPunctuation,"$1")},e[0],this.lexer,this.rules)}}reflink(n,e){let t;if((t=this.rules.inline.reflink.exec(n))||(t=this.rules.inline.nolink.exec(n))){let s=(t[2]||t[1]).replace(this.rules.other.multipleSpaceGlobal," "),r=e[s.toLowerCase()];if(!r){let i=t[0].charAt(0);return{type:"text",raw:i,text:i}}return xe(t,r,t[0],this.lexer,this.rules)}}emStrong(n,e,t=""){let s=this.rules.inline.emStrongLDelim.exec(n);if(!(!s||s[3]&&t.match(this.rules.other.unicodeAlphaNumeric))&&(!(s[1]||s[2])||!t||this.rules.inline.punctuation.exec(t))){let r=[...s[0]].length-1,i,u,l=r,c=0,o=s[0][0]==="*"?this.rules.inline.emStrongRDelimAst:this.rules.inline.emStrongRDelimUnd;for(o.lastIndex=0,e=e.slice(-1*n.length+r);(s=o.exec(e))!=null;){if(i=s[1]||s[2]||s[3]||s[4]||s[5]||s[6],!i)continue;if(u=[...i].length,s[3]||s[4]){l+=u;continue}else if((s[5]||s[6])&&r%3&&!((r+u)%3)){c+=u;continue}if(l-=u,l>0)continue;u=Math.min(u,u+l+c);let d=[...s[0]][0].length,g=n.slice(0,r+s.index+d+u);if(Math.min(r,u)%2){let p=g.slice(1,-1);return{type:"em",raw:g,text:p,tokens:this.lexer.inlineTokens(p)}}let h=g.slice(2,-2);return{type:"strong",raw:g,text:h,tokens:this.lexer.inlineTokens(h)}}}}codespan(n){let e=this.rules.inline.code.exec(n);if(e){let t=e[2].replace(this.rules.other.newLineCharGlobal," "),s=this.rules.other.nonSpaceChar.test(t),r=this.rules.other.startingSpaceChar.test(t)&&this.rules.other.endingSpaceChar.test(t);return s&&r&&(t=t.substring(1,t.length-1)),{type:"codespan",raw:e[0],text:t}}}br(n){let e=this.rules.inline.br.exec(n);if(e)return{type:"br",raw:e[0]}}del(n){let e=this.rules.inline.del.exec(n);if(e)return{type:"del",raw:e[0],text:e[2],tokens:this.lexer.inlineTokens(e[2])}}autolink(n){let e=this.rules.inline.autolink.exec(n);if(e){let t,s;return e[2]==="@"?(t=e[1],s="mailto:"+t):(t=e[1],s=t),{type:"link",raw:e[0],text:t,href:s,tokens:[{type:"text",raw:t,text:t}]}}}url(n){var t;let e;if(e=this.rules.inline.url.exec(n)){let s,r;if(e[2]==="@")s=e[0],r="mailto:"+s;else{let i;do i=e[0],e[0]=((t=this.rules.inline._backpedal.exec(e[0]))==null?void 0:t[0])??"";while(i!==e[0]);s=e[0],e[1]==="www."?r="http://"+e[0]:r=e[0]}return{type:"link",raw:e[0],text:s,href:r,tokens:[{type:"text",raw:s,text:s}]}}}inlineText(n){let e=this.rules.inline.text.exec(n);if(e){let t=this.lexer.state.inRawBlock;return{type:"text",raw:e[0],text:e[0],escaped:t}}}},y=class J{constructor(e){m(this,"tokens");m(this,"options");m(this,"state");m(this,"tokenizer");m(this,"inlineQueue");this.tokens=[],this.tokens.links=Object.create(null),this.options=e||E,this.options.tokenizer=this.options.tokenizer||new H,this.tokenizer=this.options.tokenizer,this.tokenizer.options=this.options,this.tokenizer.lexer=this,this.inlineQueue=[],this.state={inLink:!1,inRawBlock:!1,top:!0};let t={other:v,block:O.normal,inline:q.normal};this.options.pedantic?(t.block=O.pedantic,t.inline=q.pedantic):this.options.gfm&&(t.block=O.gfm,this.options.breaks?t.inline=q.breaks:t.inline=q.gfm),this.tokenizer.rules=t}static get rules(){return{block:O,inline:q}}static lex(e,t){return new J(t).lex(e)}static lexInline(e,t){return new J(t).inlineTokens(e)}lex(e){e=e.replace(v.carriageReturn,` +`),this.blockTokens(e,this.tokens);for(let t=0;t(l=o.call({lexer:this},e,t))?(e=e.substring(l.raw.length),t.push(l),!0):!1))continue;if(l=this.tokenizer.space(e)){e=e.substring(l.raw.length);let o=t.at(-1);l.raw.length===1&&o!==void 0?o.raw+=` +`:t.push(l);continue}if(l=this.tokenizer.code(e)){e=e.substring(l.raw.length);let o=t.at(-1);(o==null?void 0:o.type)==="paragraph"||(o==null?void 0:o.type)==="text"?(o.raw+=(o.raw.endsWith(` +`)?"":` +`)+l.raw,o.text+=` +`+l.text,this.inlineQueue.at(-1).src=o.text):t.push(l);continue}if(l=this.tokenizer.fences(e)){e=e.substring(l.raw.length),t.push(l);continue}if(l=this.tokenizer.heading(e)){e=e.substring(l.raw.length),t.push(l);continue}if(l=this.tokenizer.hr(e)){e=e.substring(l.raw.length),t.push(l);continue}if(l=this.tokenizer.blockquote(e)){e=e.substring(l.raw.length),t.push(l);continue}if(l=this.tokenizer.list(e)){e=e.substring(l.raw.length),t.push(l);continue}if(l=this.tokenizer.html(e)){e=e.substring(l.raw.length),t.push(l);continue}if(l=this.tokenizer.def(e)){e=e.substring(l.raw.length);let o=t.at(-1);(o==null?void 0:o.type)==="paragraph"||(o==null?void 0:o.type)==="text"?(o.raw+=(o.raw.endsWith(` +`)?"":` +`)+l.raw,o.text+=` +`+l.raw,this.inlineQueue.at(-1).src=o.text):this.tokens.links[l.tag]||(this.tokens.links[l.tag]={href:l.href,title:l.title},t.push(l));continue}if(l=this.tokenizer.table(e)){e=e.substring(l.raw.length),t.push(l);continue}if(l=this.tokenizer.lheading(e)){e=e.substring(l.raw.length),t.push(l);continue}let c=e;if((u=this.options.extensions)!=null&&u.startBlock){let o=1/0,d=e.slice(1),g;this.options.extensions.startBlock.forEach(h=>{g=h.call({lexer:this},d),typeof g=="number"&&g>=0&&(o=Math.min(o,g))}),o<1/0&&o>=0&&(c=e.substring(0,o+1))}if(this.state.top&&(l=this.tokenizer.paragraph(c))){let o=t.at(-1);s&&(o==null?void 0:o.type)==="paragraph"?(o.raw+=(o.raw.endsWith(` +`)?"":` +`)+l.raw,o.text+=` +`+l.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=o.text):t.push(l),s=c.length!==e.length,e=e.substring(l.raw.length);continue}if(l=this.tokenizer.text(e)){e=e.substring(l.raw.length);let o=t.at(-1);(o==null?void 0:o.type)==="text"?(o.raw+=(o.raw.endsWith(` +`)?"":` +`)+l.raw,o.text+=` +`+l.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=o.text):t.push(l);continue}if(e){let o="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(o);break}else throw new Error(o)}}return this.state.top=!0,t}inline(e,t=[]){return this.inlineQueue.push({src:e,tokens:t}),t}inlineTokens(e,t=[]){var c,o,d,g,h;let s=e,r=null;if(this.tokens.links){let p=Object.keys(this.tokens.links);if(p.length>0)for(;(r=this.tokenizer.rules.inline.reflinkSearch.exec(s))!=null;)p.includes(r[0].slice(r[0].lastIndexOf("[")+1,-1))&&(s=s.slice(0,r.index)+"["+"a".repeat(r[0].length-2)+"]"+s.slice(this.tokenizer.rules.inline.reflinkSearch.lastIndex))}for(;(r=this.tokenizer.rules.inline.anyPunctuation.exec(s))!=null;)s=s.slice(0,r.index)+"++"+s.slice(this.tokenizer.rules.inline.anyPunctuation.lastIndex);let i;for(;(r=this.tokenizer.rules.inline.blockSkip.exec(s))!=null;)i=r[2]?r[2].length:0,s=s.slice(0,r.index+i)+"["+"a".repeat(r[0].length-i-2)+"]"+s.slice(this.tokenizer.rules.inline.blockSkip.lastIndex);s=((o=(c=this.options.hooks)==null?void 0:c.emStrongMask)==null?void 0:o.call({lexer:this},s))??s;let u=!1,l="";for(;e;){u||(l=""),u=!1;let p;if((g=(d=this.options.extensions)==null?void 0:d.inline)!=null&&g.some(k=>(p=k.call({lexer:this},e,t))?(e=e.substring(p.raw.length),t.push(p),!0):!1))continue;if(p=this.tokenizer.escape(e)){e=e.substring(p.raw.length),t.push(p);continue}if(p=this.tokenizer.tag(e)){e=e.substring(p.raw.length),t.push(p);continue}if(p=this.tokenizer.link(e)){e=e.substring(p.raw.length),t.push(p);continue}if(p=this.tokenizer.reflink(e,this.tokens.links)){e=e.substring(p.raw.length);let k=t.at(-1);p.type==="text"&&(k==null?void 0:k.type)==="text"?(k.raw+=p.raw,k.text+=p.text):t.push(p);continue}if(p=this.tokenizer.emStrong(e,s,l)){e=e.substring(p.raw.length),t.push(p);continue}if(p=this.tokenizer.codespan(e)){e=e.substring(p.raw.length),t.push(p);continue}if(p=this.tokenizer.br(e)){e=e.substring(p.raw.length),t.push(p);continue}if(p=this.tokenizer.del(e)){e=e.substring(p.raw.length),t.push(p);continue}if(p=this.tokenizer.autolink(e)){e=e.substring(p.raw.length),t.push(p);continue}if(!this.state.inLink&&(p=this.tokenizer.url(e))){e=e.substring(p.raw.length),t.push(p);continue}let b=e;if((h=this.options.extensions)!=null&&h.startInline){let k=1/0,C=e.slice(1),R;this.options.extensions.startInline.forEach(Q=>{R=Q.call({lexer:this},C),typeof R=="number"&&R>=0&&(k=Math.min(k,R))}),k<1/0&&k>=0&&(b=e.substring(0,k+1))}if(p=this.tokenizer.inlineText(b)){e=e.substring(p.raw.length),p.raw.slice(-1)!=="_"&&(l=p.raw.slice(-1)),u=!0;let k=t.at(-1);(k==null?void 0:k.type)==="text"?(k.raw+=p.raw,k.text+=p.text):t.push(p);continue}if(e){let k="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(k);break}else throw new Error(k)}}return t}},U=class{constructor(n){m(this,"options");m(this,"parser");this.options=n||E}space(n){return""}code({text:n,lang:e,escaped:t}){var i;let s=(i=(e||"").match(v.notSpaceStart))==null?void 0:i[0],r=n.replace(v.endingNewline,"")+` +`;return s?'
'+(t?r:$(r,!0))+`
+`:"
"+(t?r:$(r,!0))+`
+`}blockquote({tokens:n}){return`
+${this.parser.parse(n)}
+`}html({text:n}){return n}def(n){return""}heading({tokens:n,depth:e}){return`${this.parser.parseInline(n)} +`}hr(n){return`
+`}list(n){let e=n.ordered,t=n.start,s="";for(let u=0;u +`+s+" +`}listitem(n){return`
  • ${this.parser.parse(n.tokens)}
  • +`}checkbox({checked:n}){return" '}paragraph({tokens:n}){return`

    ${this.parser.parseInline(n)}

    +`}table(n){let e="",t="";for(let r=0;r${s}`),` + +`+e+` +`+s+`
    +`}tablerow({text:n}){return` +${n} +`}tablecell(n){let e=this.parser.parseInline(n.tokens),t=n.header?"th":"td";return(n.align?`<${t} align="${n.align}">`:`<${t}>`)+e+` +`}strong({tokens:n}){return`${this.parser.parseInline(n)}`}em({tokens:n}){return`${this.parser.parseInline(n)}`}codespan({text:n}){return`${$(n,!0)}`}br(n){return"
    "}del({tokens:n}){return`${this.parser.parseInline(n)}`}link({href:n,title:e,tokens:t}){let s=this.parser.parseInline(t),r=fe(n);if(r===null)return s;n=r;let i='
    ",i}image({href:n,title:e,text:t,tokens:s}){s&&(t=this.parser.parseInline(s,this.parser.textRenderer));let r=fe(n);if(r===null)return $(t);n=r;let i=`${t}{let c=u[l].flat(1/0);t=t.concat(this.walkTokens(c,e))}):u.tokens&&(t=t.concat(this.walkTokens(u.tokens,e)))}}return t}use(...n){let e=this.defaults.extensions||{renderers:{},childTokens:{}};return n.forEach(t=>{let s={...t};if(s.async=this.defaults.async||s.async||!1,t.extensions&&(t.extensions.forEach(r=>{if(!r.name)throw new Error("extension name required");if("renderer"in r){let i=e.renderers[r.name];i?e.renderers[r.name]=function(...u){let l=r.renderer.apply(this,u);return l===!1&&(l=i.apply(this,u)),l}:e.renderers[r.name]=r.renderer}if("tokenizer"in r){if(!r.level||r.level!=="block"&&r.level!=="inline")throw new Error("extension level must be 'block' or 'inline'");let i=e[r.level];i?i.unshift(r.tokenizer):e[r.level]=[r.tokenizer],r.start&&(r.level==="block"?e.startBlock?e.startBlock.push(r.start):e.startBlock=[r.start]:r.level==="inline"&&(e.startInline?e.startInline.push(r.start):e.startInline=[r.start]))}"childTokens"in r&&r.childTokens&&(e.childTokens[r.name]=r.childTokens)}),s.extensions=e),t.renderer){let r=this.defaults.renderer||new U(this.defaults);for(let i in t.renderer){if(!(i in r))throw new Error(`renderer '${i}' does not exist`);if(["options","parser"].includes(i))continue;let u=i,l=t.renderer[u],c=r[u];r[u]=(...o)=>{let d=l.apply(r,o);return d===!1&&(d=c.apply(r,o)),d||""}}s.renderer=r}if(t.tokenizer){let r=this.defaults.tokenizer||new H(this.defaults);for(let i in t.tokenizer){if(!(i in r))throw new Error(`tokenizer '${i}' does not exist`);if(["options","rules","lexer"].includes(i))continue;let u=i,l=t.tokenizer[u],c=r[u];r[u]=(...o)=>{let d=l.apply(r,o);return d===!1&&(d=c.apply(r,o)),d}}s.tokenizer=r}if(t.hooks){let r=this.defaults.hooks||new F;for(let i in t.hooks){if(!(i in r))throw new Error(`hook '${i}' does not exist`);if(["options","block"].includes(i))continue;let u=i,l=t.hooks[u],c=r[u];F.passThroughHooks.has(i)?r[u]=o=>{if(this.defaults.async&&F.passThroughHooksRespectAsync.has(i))return(async()=>{let g=await l.call(r,o);return c.call(r,g)})();let d=l.call(r,o);return c.call(r,d)}:r[u]=(...o)=>{if(this.defaults.async)return(async()=>{let g=await l.apply(r,o);return g===!1&&(g=await c.apply(r,o)),g})();let d=l.apply(r,o);return d===!1&&(d=c.apply(r,o)),d}}s.hooks=r}if(t.walkTokens){let r=this.defaults.walkTokens,i=t.walkTokens;s.walkTokens=function(u){let l=[];return l.push(i.call(this,u)),r&&(l=l.concat(r.call(this,u))),l}}this.defaults={...this.defaults,...s}}),this}setOptions(n){return this.defaults={...this.defaults,...n},this}lexer(n,e){return y.lex(n,e??this.defaults)}parser(n,e){return S.parse(n,e??this.defaults)}parseMarkdown(n){return(e,t)=>{let s={...t},r={...this.defaults,...s},i=this.onError(!!r.silent,!!r.async);if(this.defaults.async===!0&&s.async===!1)return i(new Error("marked(): The async option was set to true by an extension. Remove async: false from the parse options object to return a Promise."));if(typeof e>"u"||e===null)return i(new Error("marked(): input parameter is undefined or null"));if(typeof e!="string")return i(new Error("marked(): input parameter is of type "+Object.prototype.toString.call(e)+", string expected"));if(r.hooks&&(r.hooks.options=r,r.hooks.block=n),r.async)return(async()=>{let u=r.hooks?await r.hooks.preprocess(e):e,l=await(r.hooks?await r.hooks.provideLexer():n?y.lex:y.lexInline)(u,r),c=r.hooks?await r.hooks.processAllTokens(l):l;r.walkTokens&&await Promise.all(this.walkTokens(c,r.walkTokens));let o=await(r.hooks?await r.hooks.provideParser():n?S.parse:S.parseInline)(c,r);return r.hooks?await r.hooks.postprocess(o):o})().catch(i);try{r.hooks&&(e=r.hooks.preprocess(e));let u=(r.hooks?r.hooks.provideLexer():n?y.lex:y.lexInline)(e,r);r.hooks&&(u=r.hooks.processAllTokens(u)),r.walkTokens&&this.walkTokens(u,r.walkTokens);let l=(r.hooks?r.hooks.provideParser():n?S.parse:S.parseInline)(u,r);return r.hooks&&(l=r.hooks.postprocess(l)),l}catch(u){return i(u)}}}onError(n,e){return t=>{if(t.message+=` +Please report this to https://github.com/markedjs/marked.`,n){let s="

    An error occurred:

    "+$(t.message+"",!0)+"
    ";return e?Promise.resolve(s):s}if(e)return Promise.reject(t);throw t}}},L=new mt;function x(n,e){return L.parse(n,e)}x.options=x.setOptions=function(n){return L.setOptions(n),x.defaults=L.defaults,me(x.defaults),x};x.getDefaults=_;x.defaults=E;x.use=function(...n){return L.use(...n),x.defaults=L.defaults,me(x.defaults),x};x.walkTokens=function(n,e){return L.walkTokens(n,e)};x.parseInline=L.parseInline;x.Parser=S;x.parser=S.parse;x.Renderer=U;x.TextRenderer=ae;x.Lexer=y;x.lexer=y.lex;x.Tokenizer=H;x.Hooks=F;x.parse=x;x.options;x.setOptions;x.use;x.walkTokens;x.parseInline;S.parse;y.lex;const wt={name:"Documentation",setup(){const n=Be(),e=I(!1),t=I(null),s=I(""),r=I(""),i=I(!1),u=I(null),l=I(!1);x.setOptions({breaks:!0,gfm:!0});const c=qe(()=>s.value?x.parse(s.value):"");return{showCloudQuickStart:e,currentDoc:t,docContent:s,docTitle:r,loadingDoc:i,docError:u,downloadingPDF:l,renderedMarkdown:c,viewDoc:async g=>{t.value=g,i.value=!0,u.value=null,s.value="";try{const h=await de.docs.get(g,"json");s.value=h.data.content,r.value=h.data.title}catch(h){console.error("Failed to load documentation:",h),u.value="Failed to load documentation. Please try again.",n.error("Failed to load documentation")}finally{i.value=!1}},downloadPDF:async()=>{l.value=!0;try{const g=await de.docs.downloadPDF(),h=new Blob([g.data],{type:"application/pdf"}),p=window.URL.createObjectURL(h),b=document.createElement("a");b.href=p;const k=g.headers["content-disposition"];let C="Depl0y_Documentation.pdf";if(k){const R=k.match(/filename="?(.+)"?/);R&&(C=R[1])}b.setAttribute("download",C),document.body.appendChild(b),b.click(),b.remove(),window.URL.revokeObjectURL(p),n.success("Documentation PDF downloaded successfully!")}catch(g){console.error("Failed to download PDF:",g),n.error("Failed to download PDF. Please try again.")}finally{l.value=!1}}}}},vt={class:"documentation-page"},yt={class:"doc-grid"},St={class:"doc-card highlight"},Rt={class:"doc-actions"},$t={class:"modal-content doc-modal"},Ct={class:"modal-header"},Tt={class:"modal-body"},At={class:"quick-start-content"},zt={class:"step"},Dt={class:"action-buttons"},Pt={class:"modal-content doc-modal doc-viewer"},It={class:"modal-header"},Lt={class:"modal-body"},Et={key:0,class:"loading"},Bt={key:1,class:"error"},qt=["innerHTML"],Mt={class:"help-section"},Ft={class:"help-grid"},Gt={class:"help-card"},Zt={class:"help-card"},Qt={class:"help-card"},Ot={class:"pdf-download-section"},Nt={class:"pdf-card"},Vt={class:"pdf-actions"},Ht=["disabled"],Ut={key:0},jt={key:1};function Wt(n,e,t,s,r,i){const u=Ee("router-link");return A(),T("div",vt,[e[89]||(e[89]=a("div",{class:"page-header"},[a("h1",null,"📖 Documentation"),a("p",null,"Learn how to use Depl0y and its features")],-1)),a("div",yt,[a("div",{class:"doc-card",onClick:e[2]||(e[2]=l=>s.viewDoc("install"))},[e[26]||(e[26]=a("div",{class:"doc-icon"},"📥",-1)),e[27]||(e[27]=a("h2",null,"Installation Guide",-1)),e[28]||(e[28]=a("p",null,"One-line installer and complete installation instructions for Ubuntu and Debian servers.",-1)),e[29]||(e[29]=a("div",{class:"doc-meta"},[a("span",{class:"badge badge-primary"},"Getting Started"),a("span",{class:"read-time"},"5 min read")],-1)),a("div",{class:"doc-actions",onClick:e[1]||(e[1]=z(()=>{},["stop"]))},[a("button",{onClick:e[0]||(e[0]=l=>s.viewDoc("install")),class:"btn btn-primary"}," View Guide ")])]),a("div",{class:"doc-card",onClick:e[5]||(e[5]=l=>s.viewDoc("deployment"))},[e[30]||(e[30]=a("div",{class:"doc-icon"},"🚀",-1)),e[31]||(e[31]=a("h2",null,"Deployment Guide",-1)),e[32]||(e[32]=a("p",null,"Learn how to deploy code changes, create releases, and manage updates.",-1)),e[33]||(e[33]=a("div",{class:"doc-meta"},[a("span",{class:"badge badge-warning"},"For Developers"),a("span",{class:"read-time"},"15 min read")],-1)),a("div",{class:"doc-actions",onClick:e[4]||(e[4]=z(()=>{},["stop"]))},[a("button",{onClick:e[3]||(e[3]=l=>s.viewDoc("deployment")),class:"btn btn-primary"}," View Guide ")])]),a("div",{class:"doc-card featured",onClick:e[9]||(e[9]=l=>s.viewDoc("cloud-quickstart"))},[e[34]||(e[34]=a("div",{class:"doc-icon"},"⚡",-1)),e[35]||(e[35]=a("h2",null,"Cloud Images - Quick Start",-1)),e[36]||(e[36]=a("p",null,"Get started with ultra-fast 30-second VM deployments using cloud images.",-1)),e[37]||(e[37]=a("div",{class:"doc-meta"},[a("span",{class:"badge badge-success"},"Recommended"),a("span",{class:"read-time"},"3 min read")],-1)),a("div",{class:"doc-actions",onClick:e[8]||(e[8]=z(()=>{},["stop"]))},[a("button",{onClick:e[6]||(e[6]=l=>s.viewDoc("cloud-quickstart")),class:"btn btn-primary"}," View Guide "),a("button",{onClick:e[7]||(e[7]=l=>s.showCloudQuickStart=!0),class:"btn btn-outline"}," Quick Preview ")])]),a("div",{class:"doc-card",onClick:e[12]||(e[12]=l=>s.viewDoc("cloud-guide"))},[e[38]||(e[38]=a("div",{class:"doc-icon"},"☁️",-1)),e[39]||(e[39]=a("h2",null,"Cloud Images - Complete Guide",-1)),e[40]||(e[40]=a("p",null,"Comprehensive 50+ page guide covering setup, usage, troubleshooting, and technical details.",-1)),e[41]||(e[41]=a("div",{class:"doc-meta"},[a("span",{class:"badge badge-info"},"Advanced"),a("span",{class:"read-time"},"20 min read")],-1)),a("div",{class:"doc-actions",onClick:e[11]||(e[11]=z(()=>{},["stop"]))},[a("button",{onClick:e[10]||(e[10]=l=>s.viewDoc("cloud-guide")),class:"btn btn-primary"}," View Guide ")])]),a("div",{class:"doc-card",onClick:e[15]||(e[15]=l=>s.viewDoc("readme"))},[e[42]||(e[42]=a("div",{class:"doc-icon"},"📘",-1)),e[43]||(e[43]=a("h2",null,"Getting Started with Depl0y",-1)),e[44]||(e[44]=a("p",null,"Installation, configuration, and basic usage of Depl0y.",-1)),e[45]||(e[45]=a("div",{class:"doc-meta"},[a("span",{class:"badge badge-primary"},"Basics"),a("span",{class:"read-time"},"10 min read")],-1)),a("div",{class:"doc-actions",onClick:e[14]||(e[14]=z(()=>{},["stop"]))},[a("button",{onClick:e[13]||(e[13]=l=>s.viewDoc("readme")),class:"btn btn-primary"}," View README ")])]),a("div",{class:"doc-card",onClick:e[18]||(e[18]=l=>s.viewDoc("proxmox-api-tokens"))},[e[46]||(e[46]=a("div",{class:"doc-icon"},"🔑",-1)),e[47]||(e[47]=a("h2",null,"Proxmox API Tokens",-1)),e[48]||(e[48]=a("p",null,"How to create and configure Proxmox API tokens for Depl0y.",-1)),e[49]||(e[49]=a("div",{class:"doc-meta"},[a("span",{class:"badge badge-warning"},"Setup"),a("span",{class:"read-time"},"5 min read")],-1)),a("div",{class:"doc-actions",onClick:e[17]||(e[17]=z(()=>{},["stop"]))},[a("button",{onClick:e[16]||(e[16]=l=>s.viewDoc("proxmox-api-tokens")),class:"btn btn-primary"}," View Guide ")])]),a("div",St,[e[51]||(e[51]=a("div",{class:"doc-icon"},"🚀",-1)),e[52]||(e[52]=a("h2",null,"Cloud Image Setup Status",-1)),e[53]||(e[53]=a("p",null,"Check if cloud images are configured and run the setup script.",-1)),e[54]||(e[54]=a("div",{class:"doc-meta"},[a("span",{class:"badge badge-success"},"Interactive")],-1)),a("div",Rt,[D(u,{to:"/settings",class:"btn btn-primary"},{default:P(()=>[...e[50]||(e[50]=[w(" Go to Settings ",-1)])]),_:1})])]),e[55]||(e[55]=ce('
    ',1))]),s.showCloudQuickStart?(A(),T("div",{key:0,class:"modal",onClick:e[22]||(e[22]=z(l=>s.showCloudQuickStart=!1,["self"]))},[a("div",$t,[a("div",Ct,[e[56]||(e[56]=a("h3",null,"⚡ Cloud Images - Quick Start",-1)),a("button",{onClick:e[19]||(e[19]=l=>s.showCloudQuickStart=!1),class:"btn-close"},"×")]),a("div",Tt,[a("div",At,[e[71]||(e[71]=a("h4",null,"What Are Cloud Images?",-1)),e[72]||(e[72]=a("p",null,[w("Cloud images let you deploy VMs in "),a("strong",null,"30 seconds"),w(" instead of 20 minutes. No manual OS installation needed!")],-1)),e[73]||(e[73]=a("h4",null,"One-Time Setup (Takes 1 Minute)",-1)),a("div",zt,[e[60]||(e[60]=a("strong",null,"Step 1: Check Status in Web UI",-1)),a("p",null,[e[58]||(e[58]=w("Go to ",-1)),D(u,{to:"/settings"},{default:P(()=>[...e[57]||(e[57]=[w("Settings",-1)])]),_:1}),e[59]||(e[59]=w(' → Look for "Cloud Image Setup" section',-1))]),e[61]||(e[61]=a("ul",null,[a("li",null,'✅ Green box: Already configured! Skip to "Using Cloud Images" below.'),a("li",null,"⚠️ Yellow box: Setup needed. Continue to Step 2.")],-1))]),e[74]||(e[74]=ce('
    Step 2: Run Setup Script

    SSH to your Depl0y server and run:

    sudo /tmp/enable_cloud_images.sh
    Step 3: Enter Password

    When prompted, enter your Proxmox root password

    Step 4: Done!

    You'll see: ✅ SUCCESS! Cloud images are now fully configured!

    Using Cloud Images

    ',4)),a("ol",null,[a("li",null,[e[63]||(e[63]=w("Go to ",-1)),D(u,{to:"/vms/create"},{default:P(()=>[...e[62]||(e[62]=[w("Create VM",-1)])]),_:1})]),e[64]||(e[64]=a("li",null,[w("Select "),a("strong",null,'"Cloud Image (Fast)"'),w(" installation method")],-1)),e[65]||(e[65]=a("li",null,"Choose a cloud image (Ubuntu 24.04, Debian 12, etc.)",-1)),e[66]||(e[66]=a("li",null,"Configure CPU, RAM, disk size",-1)),e[67]||(e[67]=a("li",null,"Enter your credentials",-1)),e[68]||(e[68]=a("li",null,'Click "Create VM"',-1))]),e[75]||(e[75]=a("div",{class:"info-box"},[a("strong",null,"Deployment Times:"),a("ul",null,[a("li",null,[a("strong",null,"First time:"),w(" 5-10 minutes (creates template)")]),a("li",null,[a("strong",null,"Every time after:"),w(" 30 seconds ⚡")])])],-1)),a("div",Dt,[D(u,{to:"/settings",class:"btn btn-primary",onClick:e[20]||(e[20]=l=>s.showCloudQuickStart=!1)},{default:P(()=>[...e[69]||(e[69]=[w(" Go to Settings ",-1)])]),_:1}),D(u,{to:"/vms/create",class:"btn btn-success",onClick:e[21]||(e[21]=l=>s.showCloudQuickStart=!1)},{default:P(()=>[...e[70]||(e[70]=[w(" Create VM ",-1)])]),_:1})])])])])])):ue("",!0),s.currentDoc?(A(),T("div",{key:1,class:"modal",onClick:e[24]||(e[24]=z(l=>s.currentDoc=null,["self"]))},[a("div",Pt,[a("div",It,[a("h3",null,pe(s.docTitle),1),a("button",{onClick:e[23]||(e[23]=l=>s.currentDoc=null),class:"btn-close"},"×")]),a("div",Lt,[s.loadingDoc?(A(),T("div",Et,[...e[76]||(e[76]=[a("p",null,"Loading documentation...",-1)])])):s.docError?(A(),T("div",Bt,[a("p",null,pe(s.docError),1)])):(A(),T("div",{key:2,class:"markdown-content",innerHTML:s.renderedMarkdown},null,8,qt))])])])):ue("",!0),a("div",Mt,[e[86]||(e[86]=a("h2",null,"📞 Need Help?",-1)),a("div",Ft,[a("div",Gt,[e[78]||(e[78]=a("h3",null,"💬 Report an Issue",-1)),e[79]||(e[79]=a("p",null,"Found a bug or have a feature request?",-1)),D(u,{to:"/bug-report",class:"btn btn-outline"},{default:P(()=>[...e[77]||(e[77]=[w("Report Bug",-1)])]),_:1})]),a("div",Zt,[e[81]||(e[81]=a("h3",null,"🔧 Check Logs",-1)),e[82]||(e[82]=a("p",null,"View backend logs for troubleshooting",-1)),D(u,{to:"/settings",class:"btn btn-outline"},{default:P(()=>[...e[80]||(e[80]=[w("View Logs",-1)])]),_:1})]),a("div",Qt,[e[84]||(e[84]=a("h3",null,"⚙️ System Settings",-1)),e[85]||(e[85]=a("p",null,"Configure Depl0y and check cloud image setup",-1)),D(u,{to:"/settings",class:"btn btn-outline"},{default:P(()=>[...e[83]||(e[83]=[w("Settings",-1)])]),_:1})])])]),a("div",Ot,[a("div",Nt,[e[87]||(e[87]=a("div",{class:"pdf-icon"},"📄",-1)),e[88]||(e[88]=a("div",{class:"pdf-content"},[a("h3",null,"Download Complete Documentation"),a("p",null,"Get all documentation in a single PDF file for offline reading"),a("p",{class:"text-sm text-muted"},"Includes: Installation, Deployment, Cloud Images, and all guides")],-1)),a("div",Vt,[a("button",{onClick:e[25]||(e[25]=(...l)=>s.downloadPDF&&s.downloadPDF(...l)),class:"btn btn-primary btn-lg",disabled:s.downloadingPDF},[s.downloadingPDF?(A(),T("span",Ut,"📥 Generating PDF...")):(A(),T("span",jt,"📥 Download PDF"))],8,Ht)])])])])}const Jt=Le(wt,[["render",Wt],["__scopeId","data-v-bb27aa73"]]);export{Jt as default}; diff --git a/src/frontend/dist/assets/Documentation-P_J1PI26.css b/src/frontend/dist/assets/Documentation-P_J1PI26.css new file mode 100644 index 0000000..3f2d2de --- /dev/null +++ b/src/frontend/dist/assets/Documentation-P_J1PI26.css @@ -0,0 +1 @@ +.documentation-page[data-v-bb27aa73]{max-width:1400px;margin:0 auto}.page-header[data-v-bb27aa73]{margin-bottom:3rem}.page-header h1[data-v-bb27aa73]{font-size:2.5rem;margin-bottom:.5rem;color:var(--text-primary)}.page-header p[data-v-bb27aa73]{font-size:1.125rem;color:var(--text-secondary)}.doc-grid[data-v-bb27aa73]{display:grid;grid-template-columns:repeat(auto-fit,minmax(350px,1fr));gap:2rem;margin-bottom:3rem}.doc-card[data-v-bb27aa73]{background:#fff;border-radius:.75rem;padding:2rem;box-shadow:var(--shadow-md);transition:transform .2s,box-shadow .2s;border:2px solid transparent;cursor:pointer}.doc-card[data-v-bb27aa73]:hover{transform:translateY(-4px);box-shadow:var(--shadow-lg)}.doc-card.featured[data-v-bb27aa73]{border-color:var(--primary-color);background:linear-gradient(135deg,#2563eb0d,#9333ea0d)}.doc-card.highlight[data-v-bb27aa73]{border-color:#10b981;background:linear-gradient(135deg,#10b9810d,#0596690d);cursor:default}.doc-icon[data-v-bb27aa73]{font-size:3rem;margin-bottom:1rem}.doc-card h2[data-v-bb27aa73]{font-size:1.5rem;margin-bottom:1rem;color:var(--text-primary)}.doc-card p[data-v-bb27aa73]{color:var(--text-secondary);margin-bottom:1.5rem;line-height:1.6}.doc-meta[data-v-bb27aa73]{display:flex;align-items:center;gap:1rem;margin-bottom:1.5rem}.badge[data-v-bb27aa73]{padding:.25rem .75rem;border-radius:1rem;font-size:.75rem;font-weight:600;text-transform:uppercase}.badge-success[data-v-bb27aa73]{background:#d1fae5;color:#065f46}.badge-info[data-v-bb27aa73]{background:#dbeafe;color:#1e40af}.badge-primary[data-v-bb27aa73]{background:#e0e7ff;color:#4338ca}.badge-warning[data-v-bb27aa73]{background:#fef3c7;color:#92400e}.read-time[data-v-bb27aa73]{font-size:.875rem;color:var(--text-secondary)}.doc-actions[data-v-bb27aa73]{display:flex;gap:.75rem;flex-wrap:wrap}.help-section[data-v-bb27aa73]{margin-top:4rem;padding:2rem;background:linear-gradient(135deg,#2563eb0d,#9333ea0d);border-radius:.75rem}.help-section h2[data-v-bb27aa73]{text-align:center;margin-bottom:2rem;color:var(--text-primary)}.help-grid[data-v-bb27aa73]{display:grid;grid-template-columns:repeat(auto-fit,minmax(250px,1fr));gap:2rem}.help-card[data-v-bb27aa73]{background:#fff;padding:1.5rem;border-radius:.5rem;text-align:center}.help-card h3[data-v-bb27aa73]{font-size:1.25rem;margin-bottom:.5rem;color:var(--text-primary)}.help-card p[data-v-bb27aa73]{color:var(--text-secondary);margin-bottom:1rem}.modal[data-v-bb27aa73]{position:fixed;top:0;left:0;right:0;bottom:0;background-color:#00000080;display:flex;align-items:center;justify-content:center;z-index:1000;padding:1rem}.modal-content.doc-modal[data-v-bb27aa73]{background:#fff;border-radius:.75rem;max-width:900px;width:100%;max-height:90vh;overflow-y:auto;box-shadow:var(--shadow-lg)}.modal-content.doc-viewer[data-v-bb27aa73]{max-width:1200px}.modal-header[data-v-bb27aa73]{padding:1.5rem;border-bottom:1px solid var(--border-color);display:flex;justify-content:space-between;align-items:center;background:linear-gradient(135deg,#2563eb0d,#9333ea0d)}.modal-header h3[data-v-bb27aa73]{margin:0;font-size:1.75rem}.btn-close[data-v-bb27aa73]{background:none;border:none;font-size:2rem;cursor:pointer;color:var(--text-secondary);line-height:1}.modal-body[data-v-bb27aa73]{padding:2rem}.loading[data-v-bb27aa73],.error[data-v-bb27aa73]{text-align:center;padding:2rem}.error[data-v-bb27aa73]{color:#dc2626}.markdown-content[data-v-bb27aa73]{line-height:1.8;color:var(--text-primary)}.markdown-content[data-v-bb27aa73] h1,.markdown-content[data-v-bb27aa73] h2,.markdown-content[data-v-bb27aa73] h3,.markdown-content[data-v-bb27aa73] h4{margin-top:2rem;margin-bottom:1rem;color:var(--primary-color)}.markdown-content[data-v-bb27aa73] h1:first-child,.markdown-content[data-v-bb27aa73] h2:first-child{margin-top:0}.markdown-content[data-v-bb27aa73] p{margin-bottom:1rem}.markdown-content[data-v-bb27aa73] code{background:#1e293b;color:#10b981;padding:.125rem .375rem;border-radius:.25rem;font-size:.9rem;font-family:monospace}.markdown-content[data-v-bb27aa73] pre{background:#1e293b;color:#e2e8f0;padding:1rem;border-radius:.375rem;overflow-x:auto;margin:1rem 0}.markdown-content[data-v-bb27aa73] pre code{background:none;padding:0;color:inherit}.markdown-content[data-v-bb27aa73] ul,.markdown-content[data-v-bb27aa73] ol{margin-left:1.5rem;margin-bottom:1rem}.markdown-content[data-v-bb27aa73] li{margin-bottom:.5rem}.markdown-content[data-v-bb27aa73] table{width:100%;border-collapse:collapse;margin:1rem 0}.markdown-content[data-v-bb27aa73] th,.markdown-content[data-v-bb27aa73] td{padding:.75rem;border:1px solid var(--border-color);text-align:left}.markdown-content[data-v-bb27aa73] th{background:#f9fafb;font-weight:600}.markdown-content[data-v-bb27aa73] blockquote{border-left:4px solid var(--primary-color);padding-left:1rem;margin:1rem 0;color:var(--text-secondary);font-style:italic}.markdown-content[data-v-bb27aa73] a{color:var(--primary-color);text-decoration:underline}.markdown-content[data-v-bb27aa73] hr{border:none;border-top:2px solid var(--border-color);margin:2rem 0}.quick-start-content h4[data-v-bb27aa73]{margin-top:2rem;margin-bottom:1rem;color:var(--primary-color);font-size:1.25rem}.quick-start-content h4[data-v-bb27aa73]:first-child{margin-top:0}.quick-start-content p[data-v-bb27aa73]{margin-bottom:1rem;line-height:1.6}.quick-start-content ul[data-v-bb27aa73],.quick-start-content ol[data-v-bb27aa73]{margin-left:1.5rem;margin-bottom:1rem}.quick-start-content li[data-v-bb27aa73]{margin-bottom:.5rem}.step[data-v-bb27aa73]{background:#f9fafb;padding:1.5rem;border-radius:.5rem;margin-bottom:1rem;border-left:4px solid var(--primary-color)}.step strong[data-v-bb27aa73]{display:block;margin-bottom:.5rem;color:var(--primary-color)}.code-block[data-v-bb27aa73]{background:#1e293b;padding:1rem;border-radius:.375rem;margin:1rem 0}.code-block code[data-v-bb27aa73]{color:#10b981;font-family:monospace;font-size:.95rem}.success-text[data-v-bb27aa73]{color:#10b981;font-weight:600}.info-box[data-v-bb27aa73]{background:#dbeafe;border:2px solid #3b82f6;border-radius:.5rem;padding:1.5rem;margin:1.5rem 0}.info-box strong[data-v-bb27aa73]{color:#1e40af;display:block;margin-bottom:.5rem}.info-box ul[data-v-bb27aa73]{margin:0;padding-left:1.5rem}.action-buttons[data-v-bb27aa73]{display:flex;gap:1rem;margin-top:2rem;flex-wrap:wrap}@media (max-width: 768px){.doc-grid[data-v-bb27aa73],.help-grid[data-v-bb27aa73]{grid-template-columns:1fr}.page-header h1[data-v-bb27aa73]{font-size:2rem}.action-buttons[data-v-bb27aa73]{flex-direction:column}.action-buttons .btn[data-v-bb27aa73]{width:100%}.pdf-card[data-v-bb27aa73]{flex-direction:column}.pdf-content[data-v-bb27aa73]{text-align:center}.pdf-actions[data-v-bb27aa73],.pdf-actions .btn[data-v-bb27aa73]{width:100%}}.pdf-download-section[data-v-bb27aa73]{margin-top:3rem;padding:2rem;background:linear-gradient(135deg,#ef44440d,#dc26260d);border-radius:.75rem;border:2px solid #ef4444}.pdf-card[data-v-bb27aa73]{display:flex;align-items:center;gap:2rem;background:#fff;padding:2rem;border-radius:.75rem;box-shadow:var(--shadow-md)}.pdf-icon[data-v-bb27aa73]{font-size:4rem;filter:drop-shadow(0 4px 6px rgba(0,0,0,.1))}.pdf-content[data-v-bb27aa73]{flex:1}.pdf-content h3[data-v-bb27aa73]{font-size:1.5rem;margin-bottom:.5rem;color:var(--text-primary)}.pdf-content p[data-v-bb27aa73]{color:var(--text-secondary);margin-bottom:.25rem}.pdf-actions[data-v-bb27aa73]{flex-shrink:0}.btn-lg[data-v-bb27aa73]{padding:.875rem 2rem;font-size:1.125rem;font-weight:600}.text-sm[data-v-bb27aa73]{font-size:.875rem}.text-muted[data-v-bb27aa73]{color:var(--text-secondary)} diff --git a/src/frontend/dist/assets/HAManagement-BUepIRUl.js b/src/frontend/dist/assets/HAManagement-BUepIRUl.js new file mode 100644 index 0000000..79dd216 --- /dev/null +++ b/src/frontend/dist/assets/HAManagement-BUepIRUl.js @@ -0,0 +1 @@ +import{_ as Q,r as d,y as $,j as O,z as ee,o as l,c as a,d as u,a as e,F as G,p as N,m as H,t as n,e as f,b as v,C as B,A as j,v as W,E as I,w as se,T as te,l as y,g as oe,h as le,B as ae,k as ne}from"./index-DpP9cWht.js";const re={name:"HAWizard",props:{isOpen:{type:Boolean,required:!0}},emits:["close","complete"],setup(q,{emit:s}){const V=["Prerequisites","HA Group","Select VMs","Policies","Review"],t=d(0),M=d(!1),b=d(null),o=d("existing"),r=d([]),R=d(null),F=d(null),S=d(!0),z=d([]),_=d([]),U=d(!1),k=d({name:"",restricted:!1,nofailback:!1,comment:""}),g=d(!1),h=d([]),c=d([]),D=d({state:"started",max_restart:1,max_relocate:1}),T=d(!1),P=d(null);$(()=>q.isOpen,async i=>{i&&t.value===0&&(await Y(),await K(),await J())});const Y=async()=>{var i,A;M.value=!0;try{const p=(await y.ha.checkStatus()).data;b.value={quorum:p.quorum||!1,nodes:1,manager_active:p.manager_status==="active",error:p.error||null};try{const w=await y.proxmox.listHosts();if(w.data&&w.data.length>0){const m=w.data[0].id,C=await y.proxmox.listNodes(m);b.value.nodes=C.data.length}}catch(w){console.error("Failed to get node count:",w)}}catch(x){console.error("Prerequisites check failed:",x),b.value={quorum:!1,nodes:0,manager_active:!1,error:((A=(i=x.response)==null?void 0:i.data)==null?void 0:A.detail)||"Failed to check prerequisites"}}finally{M.value=!1}},K=async()=>{try{const i=await y.ha.listGroups();r.value=i.data.groups||[],i.data.message&&i.data.message.includes("migrated to rules")?(F.value=i.data.message,S.value=!1,r.value.length===0?o.value="skip":o.value="existing"):r.value.length>0?o.value="existing":o.value="new"}catch(i){console.error("Failed to load HA groups:",i)}},J=async()=>{U.value=!0;try{const i=await y.proxmox.listHosts();if(i.data&&i.data.length>0){const A=i.data[0].id,x=await y.proxmox.listNodes(A);z.value=x.data||[]}}catch(i){console.error("Failed to load nodes:",i)}finally{U.value=!1}},X=async()=>{g.value=!0;try{const i=await y.vms.list(),A=await y.ha.listResources(),x=new Set((A.data.resources||[]).map(p=>{var m;const w=(m=p.sid)==null?void 0:m.match(/vm:(\d+)/);return w?parseInt(w[1]):null}).filter(Boolean));h.value=(i.data||[]).filter(p=>p.vmid&&!x.has(p.vmid)).map(p=>({vmid:p.vmid,name:p.name,node:p.node_name||"unknown",status:p.status||"unknown"}))}catch(i){console.error("Failed to load VMs:",i)}finally{g.value=!1}},L=O(()=>{var i;switch(t.value){case 0:return((i=b.value)==null?void 0:i.quorum)===!0;case 1:return o.value==="new"&&S.value?k.value.name&&_.value.length>0:o.value==="existing"?R.value!==null:o.value==="skip";case 2:return c.value.length>0;case 3:return!0;default:return!1}}),Z=O(()=>c.value.length===0?!1:o.value==="skip"?!0:o.value==="existing"?R.value!==null:o.value==="new"?k.value.name&&_.value.length>0:!1);return{steps:V,currentStep:t,checkingPrerequisites:M,prerequisiteResults:b,groupMode:o,existingGroups:r,selectedGroup:R,groupsMessage:F,canCreateGroups:S,availableNodes:z,selectedNodes:_,loadingNodes:U,newGroup:k,loadingVMs:g,availableVMs:h,selectedVMs:c,haConfig:D,applying:T,applyResult:P,canProceed:L,canApply:Z,nextStep:async()=>{t.value{t.value>0&&(t.value--,P.value=null)},applyConfiguration:async()=>{var A,x,p,w;T.value=!0,P.value=null;const i=[];try{let m=null;if(o.value==="skip")m=null,i.push("Skipping group configuration (Proxmox 8.x)");else if(o.value==="existing")m=R.value;else if(o.value==="new"&&S.value)try{await y.ha.createGroup({group:k.value.name,nodes:_.value.join(","),restricted:k.value.restricted?1:0,nofailback:k.value.nofailback?1:0,comment:k.value.comment||null}),m=k.value.name,i.push(`Created HA group: ${k.value.name}`)}catch(C){throw new Error(`Failed to create HA group: ${((x=(A=C.response)==null?void 0:A.data)==null?void 0:x.detail)||C.message}`)}for(const C of c.value)try{const E={sid:`vm:${C}`,state:D.value.state,max_restart:D.value.max_restart,max_relocate:D.value.max_relocate};m&&(E.group=m),await y.ha.addResource(E),i.push(`Added VM ${C} to HA protection${m?` (group: ${m})`:""}`)}catch(E){i.push(`Failed to add VM ${C}: ${((w=(p=E.response)==null?void 0:p.data)==null?void 0:w.detail)||E.message}`)}P.value={success:!0,details:i},s("complete")}catch(m){P.value={success:!1,error:m.message||"Failed to apply configuration"}}finally{T.value=!1}},close:()=>{s("close"),setTimeout(()=>{t.value=0,b.value=null,c.value=[],P.value=null},300)}}}},ie={class:"wizard-modal"},de={class:"wizard-header"},ue={class:"wizard-steps"},ce={class:"step-number"},ve={class:"step-label"},pe={class:"wizard-body"},ge={key:0,class:"wizard-step"},me={key:0,class:"loading"},fe={key:1,class:"prerequisite-results"},ye={class:"icon"},be={class:"icon"},he={class:"icon"},ke={key:0,class:"error-message"},we={key:1,class:"wizard-step"},xe={key:0,class:"info-message"},Me={class:"form-group"},Ae=["disabled"],He={key:1,class:"group-selection"},Ve=["value"],Re={class:"form-group"},Se=["disabled"],_e={key:2,class:"form-group"},Ce={key:3,class:"new-group-form"},Ge={class:"form-group"},Ne={class:"form-group"},qe={key:0,class:"loading-inline"},ze={key:1,class:"empty-state-inline"},Pe={key:2,class:"node-selection"},Ue=["value"],Ee={class:"node-info"},Fe={class:"help-text"},De={class:"form-group"},We={class:"form-group"},Ie={class:"form-group"},Te={key:2,class:"wizard-step"},Be={key:0,class:"loading"},Le={key:1,class:"empty-state"},Oe={key:2,class:"vm-list"},je={class:"vm-checkbox"},Qe=["value"],Ye={class:"vm-info"},Ke={class:"vm-details"},Je={key:3,class:"selection-summary"},Xe={key:3,class:"wizard-step"},Ze={class:"form-group"},$e={class:"form-group"},es={class:"form-group"},ss={key:4,class:"wizard-step"},ts={class:"review-section"},os={class:"review-item"},ls={key:0,class:"review-item"},as={key:1,class:"review-item"},ns={key:2,class:"review-item"},rs={key:3,class:"review-item"},is={class:"review-section"},ds={class:"review-item"},us={key:0,class:"review-item"},cs={class:"review-section"},vs={class:"review-item"},ps={class:"review-item"},gs={class:"review-item"},ms={key:0,class:"loading"},fs={key:1,class:"apply-result"},ys={key:0,class:"success-message"},bs={key:0},hs={key:1,class:"error-message"},ks={class:"wizard-footer"},ws=["disabled"],xs=["disabled"];function Ms(q,s,V,t,M,b){return l(),ee(te,{to:"body"},[V.isOpen?(l(),a("div",{key:0,class:"wizard-overlay",onClick:s[18]||(s[18]=se((...o)=>t.close&&t.close(...o),["self"]))},[e("div",ie,[e("div",de,[s[19]||(s[19]=e("h2",null,"High Availability Setup Wizard",-1)),e("button",{onClick:s[0]||(s[0]=(...o)=>t.close&&t.close(...o)),class:"close-btn"},"×")]),e("div",ue,[(l(!0),a(G,null,N(t.steps,(o,r)=>(l(),a("div",{key:r,class:H(["step",{active:t.currentStep===r,completed:t.currentStep>r}])},[e("div",ce,n(r+1),1),e("div",ve,n(o),1)],2))),128))]),e("div",pe,[t.currentStep===0?(l(),a("div",ge,[s[24]||(s[24]=e("h3",null,"Checking Prerequisites",-1)),s[25]||(s[25]=e("p",{class:"step-description"},"Verifying your cluster is ready for High Availability",-1)),t.checkingPrerequisites?(l(),a("div",me,[...s[20]||(s[20]=[e("div",{class:"spinner"},null,-1),e("p",null,"Checking cluster status...",-1)])])):t.prerequisiteResults?(l(),a("div",fe,[e("div",{class:H(["check-item",t.prerequisiteResults.quorum?"success":"error"])},[e("span",ye,n(t.prerequisiteResults.quorum?"✓":"✕"),1),e("div",null,[s[21]||(s[21]=e("strong",null,"Cluster Quorum",-1)),e("p",null,n(t.prerequisiteResults.quorum?"Cluster has quorum - ready for HA":"No quorum - HA requires a multi-node cluster"),1)])],2),e("div",{class:H(["check-item",t.prerequisiteResults.nodes>=2?"success":"warning"])},[e("span",be,n(t.prerequisiteResults.nodes>=2?"✓":"⚠"),1),e("div",null,[s[22]||(s[22]=e("strong",null,"Cluster Nodes",-1)),e("p",null,n(t.prerequisiteResults.nodes)+" node(s) detected. "+n(t.prerequisiteResults.nodes>=2?"Multi-node cluster ready":"Single node detected - limited HA functionality"),1)])],2),e("div",{class:H(["check-item",t.prerequisiteResults.manager_active?"success":"warning"])},[e("span",he,n(t.prerequisiteResults.manager_active?"✓":"⚠"),1),e("div",null,[s[23]||(s[23]=e("strong",null,"HA Manager",-1)),e("p",null,n(t.prerequisiteResults.manager_active?"HA Manager is active":"HA Manager status unclear"),1)])],2),t.prerequisiteResults.error?(l(),a("div",ke,n(t.prerequisiteResults.error),1)):u("",!0)])):u("",!0)])):u("",!0),t.currentStep===1?(l(),a("div",we,[s[36]||(s[36]=e("h3",null,"Configure HA Group",-1)),s[37]||(s[37]=e("p",{class:"step-description"},"HA groups define which nodes VMs can run on",-1)),t.groupsMessage?(l(),a("div",xe,[s[26]||(s[26]=e("span",{class:"icon"},"ℹ️",-1)),e("p",null,n(t.groupsMessage),1)])):u("",!0),e("div",Me,[e("label",null,[f(e("input",{type:"radio","onUpdate:modelValue":s[1]||(s[1]=o=>t.groupMode=o),value:"existing",disabled:t.existingGroups.length===0},null,8,Ae),[[B,t.groupMode]]),v(" Use existing HA group "+n(t.existingGroups.length===0?"(none available)":""),1)])]),t.groupMode==="existing"&&t.existingGroups.length>0?(l(),a("div",He,[f(e("select",{"onUpdate:modelValue":s[2]||(s[2]=o=>t.selectedGroup=o),class:"form-control"},[s[27]||(s[27]=e("option",{value:null},"Select a group...",-1)),(l(!0),a(G,null,N(t.existingGroups,o=>(l(),a("option",{key:o.group,value:o.group},n(o.group)+" ("+n(o.nodes)+") ",9,Ve))),128))],512),[[j,t.selectedGroup]])])):u("",!0),e("div",Re,[e("label",null,[f(e("input",{type:"radio","onUpdate:modelValue":s[3]||(s[3]=o=>t.groupMode=o),value:"new",disabled:!t.canCreateGroups},null,8,Se),[[B,t.groupMode]]),v(" Create new HA group "+n(t.canCreateGroups?"":"(not supported on Proxmox 8+)"),1)])]),!t.canCreateGroups&&t.existingGroups.length===0?(l(),a("div",_e,[e("label",null,[f(e("input",{type:"radio","onUpdate:modelValue":s[4]||(s[4]=o=>t.groupMode=o),value:"skip"},null,512),[[B,t.groupMode]]),s[28]||(s[28]=v(" Skip group configuration (Proxmox 8.x - groups managed via web interface) ",-1))])])):u("",!0),t.groupMode==="new"?(l(),a("div",Ce,[e("div",Ge,[s[29]||(s[29]=e("label",{class:"form-label"},"Group Name *",-1)),f(e("input",{"onUpdate:modelValue":s[5]||(s[5]=o=>t.newGroup.name=o),type:"text",class:"form-control",placeholder:"e.g., production-group"},null,512),[[W,t.newGroup.name]]),s[30]||(s[30]=e("small",{class:"help-text"},"Unique identifier for this HA group",-1))]),e("div",Ne,[s[32]||(s[32]=e("label",{class:"form-label"},"Select Nodes *",-1)),t.loadingNodes?(l(),a("div",qe,[...s[31]||(s[31]=[e("div",{class:"spinner-small"},null,-1),e("span",null,"Loading nodes...",-1)])])):t.availableNodes.length===0?(l(),a("div",ze," No nodes available ")):(l(),a("div",Pe,[(l(!0),a(G,null,N(t.availableNodes,o=>(l(),a("label",{key:o.id,class:"node-checkbox"},[f(e("input",{type:"checkbox",value:o.node_name,"onUpdate:modelValue":s[6]||(s[6]=r=>t.selectedNodes=r)},null,8,Ue),[[I,t.selectedNodes]]),e("div",Ee,[e("strong",null,n(o.node_name),1),e("span",{class:H(["node-status",o.status])},n(o.status),3)])]))),128))])),e("small",Fe,"Select which nodes VMs can run on ("+n(t.selectedNodes.length)+" selected)",1)]),e("div",De,[e("label",null,[f(e("input",{type:"checkbox","onUpdate:modelValue":s[7]||(s[7]=o=>t.newGroup.restricted=o)},null,512),[[I,t.newGroup.restricted]]),s[33]||(s[33]=v(" Restricted (VMs can only run on these nodes) ",-1))])]),e("div",We,[e("label",null,[f(e("input",{type:"checkbox","onUpdate:modelValue":s[8]||(s[8]=o=>t.newGroup.nofailback=o)},null,512),[[I,t.newGroup.nofailback]]),s[34]||(s[34]=v(" No failback (VMs stay on recovery node) ",-1))])]),e("div",Ie,[s[35]||(s[35]=e("label",{class:"form-label"},"Comment (optional)",-1)),f(e("input",{"onUpdate:modelValue":s[9]||(s[9]=o=>t.newGroup.comment=o),type:"text",class:"form-control",placeholder:"Description of this group"},null,512),[[W,t.newGroup.comment]])])])):u("",!0)])):u("",!0),t.currentStep===2?(l(),a("div",Te,[s[41]||(s[41]=e("h3",null,"Select Virtual Machines",-1)),s[42]||(s[42]=e("p",{class:"step-description"},"Choose which VMs to protect with High Availability",-1)),t.loadingVMs?(l(),a("div",Be,[...s[38]||(s[38]=[e("div",{class:"spinner"},null,-1),e("p",null,"Loading virtual machines...",-1)])])):t.availableVMs.length===0?(l(),a("div",Le,[...s[39]||(s[39]=[e("p",null,"No VMs available to add to HA protection",-1)])])):(l(),a("div",Oe,[(l(!0),a(G,null,N(t.availableVMs,o=>(l(),a("div",{key:o.vmid,class:"vm-item"},[e("label",je,[f(e("input",{type:"checkbox",value:o.vmid,"onUpdate:modelValue":s[10]||(s[10]=r=>t.selectedVMs=r)},null,8,Qe),[[I,t.selectedVMs]]),e("div",Ye,[e("strong",null,n(o.name||`VM ${o.vmid}`),1),e("span",Ke,"VMID: "+n(o.vmid)+" | Node: "+n(o.node),1),e("span",{class:H(["vm-status",o.status])},n(o.status),3)])])]))),128))])),t.selectedVMs.length>0?(l(),a("div",Je,[e("strong",null,n(t.selectedVMs.length),1),s[40]||(s[40]=v(" VM(s) selected for HA protection ",-1))])):u("",!0)])):u("",!0),t.currentStep===3?(l(),a("div",Xe,[s[50]||(s[50]=e("h3",null,"Configure HA Policies",-1)),s[51]||(s[51]=e("p",{class:"step-description"},"Set restart and relocation policies for protected VMs",-1)),e("div",Ze,[s[44]||(s[44]=e("label",{class:"form-label"},"Default State",-1)),f(e("select",{"onUpdate:modelValue":s[11]||(s[11]=o=>t.haConfig.state=o),class:"form-control"},[...s[43]||(s[43]=[e("option",{value:"started"},"Started (auto-start after failure)",-1),e("option",{value:"stopped"},"Stopped (don't auto-start)",-1),e("option",{value:"ignored"},"Ignored (no HA management)",-1),e("option",{value:"disabled"},"Disabled (temporarily disable HA)",-1)])],512),[[j,t.haConfig.state]]),s[45]||(s[45]=e("small",{class:"help-text"},"How should VMs behave after a node failure",-1))]),e("div",$e,[s[46]||(s[46]=e("label",{class:"form-label"},"Max Restart Attempts",-1)),f(e("input",{"onUpdate:modelValue":s[12]||(s[12]=o=>t.haConfig.max_restart=o),type:"number",min:"0",max:"10",class:"form-control"},null,512),[[W,t.haConfig.max_restart,void 0,{number:!0}]]),s[47]||(s[47]=e("small",{class:"help-text"},"Maximum times to attempt restarting a failed VM (default: 1)",-1))]),e("div",es,[s[48]||(s[48]=e("label",{class:"form-label"},"Max Relocate Attempts",-1)),f(e("input",{"onUpdate:modelValue":s[13]||(s[13]=o=>t.haConfig.max_relocate=o),type:"number",min:"0",max:"10",class:"form-control"},null,512),[[W,t.haConfig.max_relocate,void 0,{number:!0}]]),s[49]||(s[49]=e("small",{class:"help-text"},"Maximum times to attempt relocating a VM to another node (default: 1)",-1))])])):u("",!0),t.currentStep===4?(l(),a("div",ss,[s[69]||(s[69]=e("h3",null,"Review Configuration",-1)),s[70]||(s[70]=e("p",{class:"step-description"},"Review your High Availability configuration before applying",-1)),e("div",ts,[s[57]||(s[57]=e("h4",null,"HA Group",-1)),e("div",os,[s[52]||(s[52]=e("strong",null,"Mode:",-1)),v(" "+n(t.groupMode==="new"?"Create New Group":t.groupMode==="existing"?"Use Existing Group":"Skip (Proxmox 8.x)"),1)]),t.groupMode==="new"?(l(),a("div",ls,[s[53]||(s[53]=e("strong",null,"Name:",-1)),v(" "+n(t.newGroup.name),1)])):u("",!0),t.groupMode==="new"?(l(),a("div",as,[s[54]||(s[54]=e("strong",null,"Nodes:",-1)),v(" "+n(t.selectedNodes.join(", ")),1)])):u("",!0),t.groupMode==="existing"?(l(),a("div",ns,[s[55]||(s[55]=e("strong",null,"Group:",-1)),v(" "+n(t.selectedGroup||"None selected"),1)])):u("",!0),t.groupMode==="skip"?(l(),a("div",rs,[...s[56]||(s[56]=[e("strong",null,"Note:",-1),v(" VMs will be added to HA without a group (Proxmox 8.x) ",-1)])])):u("",!0)]),e("div",is,[s[60]||(s[60]=e("h4",null,"Protected VMs",-1)),e("div",ds,[s[58]||(s[58]=e("strong",null,"Count:",-1)),v(" "+n(t.selectedVMs.length)+" VM(s) ",1)]),t.selectedVMs.length>0?(l(),a("div",us,[s[59]||(s[59]=e("strong",null,"VM IDs:",-1)),v(" "+n(t.selectedVMs.join(", ")),1)])):u("",!0)]),e("div",cs,[s[64]||(s[64]=e("h4",null,"HA Policies",-1)),e("div",vs,[s[61]||(s[61]=e("strong",null,"State:",-1)),v(" "+n(t.haConfig.state),1)]),e("div",ps,[s[62]||(s[62]=e("strong",null,"Max Restart:",-1)),v(" "+n(t.haConfig.max_restart),1)]),e("div",gs,[s[63]||(s[63]=e("strong",null,"Max Relocate:",-1)),v(" "+n(t.haConfig.max_relocate),1)])]),t.applying?(l(),a("div",ms,[...s[65]||(s[65]=[e("div",{class:"spinner"},null,-1),e("p",null,"Applying HA configuration...",-1)])])):u("",!0),t.applyResult?(l(),a("div",fs,[t.applyResult.success?(l(),a("div",ys,[s[66]||(s[66]=e("span",{class:"icon"},"✓",-1)),s[67]||(s[67]=e("p",null,"High Availability configuration applied successfully!",-1)),t.applyResult.details&&t.applyResult.details.length>0?(l(),a("ul",bs,[(l(!0),a(G,null,N(t.applyResult.details,(o,r)=>(l(),a("li",{key:r},n(o),1))),128))])):u("",!0)])):u("",!0),t.applyResult.error?(l(),a("div",hs,[s[68]||(s[68]=e("span",{class:"icon"},"✕",-1)),e("p",null,n(t.applyResult.error),1)])):u("",!0)])):u("",!0)])):u("",!0)]),e("div",ks,[t.currentStep>0&&!t.applying?(l(),a("button",{key:0,onClick:s[14]||(s[14]=(...o)=>t.previousStep&&t.previousStep(...o)),class:"btn btn-secondary"}," Previous ")):u("",!0),t.currentStept.nextStep&&t.nextStep(...o)),class:"btn btn-primary",disabled:!t.canProceed}," Next ",8,ws)):u("",!0),t.currentStep===t.steps.length-1&&!t.applyResult?(l(),a("button",{key:2,onClick:s[16]||(s[16]=(...o)=>t.applyConfiguration&&t.applyConfiguration(...o)),class:"btn btn-success",disabled:t.applying||!t.canApply},n(t.applying?"Applying...":"Apply Configuration"),9,xs)):u("",!0),t.applyResult&&t.applyResult.success?(l(),a("button",{key:3,onClick:s[17]||(s[17]=(...o)=>t.close&&t.close(...o)),class:"btn btn-primary"}," Done ")):u("",!0)])])])):u("",!0)])}const As=Q(re,[["render",Ms],["__scopeId","data-v-4e83599b"]]),Hs={name:"HAManagement",components:{HAWizard:As},setup(){const q=d([]),s=d([]),V=d({}),t=d(!1),M=d(!1),b=d(!1),o=d(null),r=d(null),R=d(null),F=d(!1),S=async()=>{var g,h;t.value=!0,o.value=null;try{const c=await y.ha.listGroups();q.value=c.data.groups||[]}catch(c){o.value=((h=(g=c.response)==null?void 0:g.data)==null?void 0:h.detail)||"Failed to load HA groups",console.error("Error loading HA groups:",c)}finally{t.value=!1}},z=async()=>{var g,h;M.value=!0,r.value=null;try{const c=await y.ha.listResources();s.value=c.data.resources||[]}catch(c){r.value=((h=(g=c.response)==null?void 0:g.data)==null?void 0:h.detail)||"Failed to load HA resources",console.error("Error loading HA resources:",c)}finally{M.value=!1}},_=async()=>{var g,h;b.value=!0,R.value=null;try{const c=await y.ha.checkStatus();V.value=c.data||{}}catch(c){R.value=((h=(g=c.response)==null?void 0:g.data)==null?void 0:h.detail)||"Failed to load HA status",console.error("Error loading HA status:",c)}finally{b.value=!1}},U=g=>({started:"badge-success",stopped:"badge-error",enabled:"badge-success",disabled:"badge-warning"})[g]||"badge-secondary",k=()=>{S(),z(),_()};return ne(()=>{S(),z(),_()}),{haGroups:q,haResources:s,haStatus:V,loading:t,loadingResources:M,loadingStatus:b,error:o,resourceError:r,statusError:R,showWizard:F,loadHAGroups:S,loadHAResources:z,loadHAStatus:_,getStatusClass:U,onWizardComplete:k}}},Vs={class:"ha-management"},Rs={class:"page-header"},Ss={class:"ha-content"},_s={class:"card"},Cs={class:"card-header"},Gs={key:0,class:"loading"},Ns={key:1,class:"error-message"},qs={key:2,class:"empty-state"},zs={key:3,class:"table-responsive"},Ps={class:"data-table"},Us={class:"card"},Es={class:"card-header"},Fs={key:0,class:"loading"},Ds={key:1,class:"error-message"},Ws={key:2,class:"empty-state"},Is={key:3,class:"table-responsive"},Ts={class:"data-table"},Bs={class:"card"},Ls={class:"card-header"},Os={key:0,class:"loading"},js={key:1,class:"error-message"},Qs={key:2,class:"status-grid"},Ys={class:"status-item"},Ks={class:"status-value"},Js={class:"status-item"},Xs={class:"status-value"},Zs={class:"status-item"},$s={class:"status-value"},et={class:"status-item"},st={class:"status-value"};function tt(q,s,V,t,M,b){const o=oe("HAWizard");return l(),a("div",Vs,[e("div",Rs,[s[6]||(s[6]=e("div",null,[e("h1",null,"High Availability Management"),e("p",{class:"subtitle"},"Manage Proxmox HA groups and resources")],-1)),e("button",{onClick:s[0]||(s[0]=r=>t.showWizard=!0),class:"btn btn-primary btn-wizard"},[...s[5]||(s[5]=[e("span",{class:"icon"},"✨",-1),v(" Setup Wizard ",-1)])])]),e("div",Ss,[e("div",_s,[e("div",Cs,[s[8]||(s[8]=e("h2",null,"HA Groups",-1)),e("button",{onClick:s[1]||(s[1]=(...r)=>t.loadHAGroups&&t.loadHAGroups(...r)),class:"btn btn-secondary"},[...s[7]||(s[7]=[e("span",{class:"icon"},"🔄",-1),v(" Refresh ",-1)])])]),t.loading?(l(),a("div",Gs,"Loading HA groups...")):t.error?(l(),a("div",Ns,n(t.error),1)):t.haGroups.length===0?(l(),a("div",qs,[...s[9]||(s[9]=[e("p",null,"No HA groups configured",-1),e("p",{class:"help-text"},"HA groups define which nodes VMs can fail over to",-1)])])):(l(),a("div",zs,[e("table",Ps,[s[10]||(s[10]=e("thead",null,[e("tr",null,[e("th",null,"Group ID"),e("th",null,"Nodes"),e("th",null,"Restricted"),e("th",null,"No Failback"),e("th",null,"Comment")])],-1)),e("tbody",null,[(l(!0),a(G,null,N(t.haGroups,r=>(l(),a("tr",{key:r.group},[e("td",null,[e("strong",null,n(r.group),1)]),e("td",null,n(r.nodes),1),e("td",null,n(r.restricted?"Yes":"No"),1),e("td",null,n(r.nofailback?"Yes":"No"),1),e("td",null,n(r.comment||"-"),1)]))),128))])])]))]),e("div",Us,[e("div",Es,[s[12]||(s[12]=e("h2",null,"HA Resources",-1)),e("button",{onClick:s[2]||(s[2]=(...r)=>t.loadHAResources&&t.loadHAResources(...r)),class:"btn btn-secondary"},[...s[11]||(s[11]=[e("span",{class:"icon"},"🔄",-1),v(" Refresh ",-1)])])]),t.loadingResources?(l(),a("div",Fs,"Loading HA resources...")):t.resourceError?(l(),a("div",Ds,n(t.resourceError),1)):t.haResources.length===0?(l(),a("div",Ws,[...s[13]||(s[13]=[e("p",null,"No HA resources configured",-1),e("p",{class:"help-text"},"Add VMs to HA protection to enable automatic failover",-1)])])):(l(),a("div",Is,[e("table",Ts,[s[14]||(s[14]=e("thead",null,[e("tr",null,[e("th",null,"Resource ID"),e("th",null,"Type"),e("th",null,"Group"),e("th",null,"State"),e("th",null,"Max Restart"),e("th",null,"Max Relocate")])],-1)),e("tbody",null,[(l(!0),a(G,null,N(t.haResources,r=>(l(),a("tr",{key:r.sid},[e("td",null,[e("strong",null,n(r.sid),1)]),e("td",null,n(r.type||"VM"),1),e("td",null,n(r.group||"-"),1),e("td",null,[e("span",{class:H(["badge",t.getStatusClass(r.state)])},n(r.state),3)]),e("td",null,n(r.max_restart),1),e("td",null,n(r.max_relocate),1)]))),128))])])]))]),e("div",Bs,[e("div",Ls,[s[16]||(s[16]=e("h2",null,"HA Status",-1)),e("button",{onClick:s[3]||(s[3]=(...r)=>t.loadHAStatus&&t.loadHAStatus(...r)),class:"btn btn-secondary"},[...s[15]||(s[15]=[e("span",{class:"icon"},"🔄",-1),v(" Refresh ",-1)])])]),t.loadingStatus?(l(),a("div",Os,"Loading HA status...")):t.statusError?(l(),a("div",js,n(t.statusError),1)):(l(),a("div",Qs,[e("div",Ys,[s[17]||(s[17]=e("div",{class:"status-label"},"Manager Status",-1)),e("div",Ks,[e("span",{class:H(["badge",t.haStatus.manager_status==="active"?"badge-success":"badge-error"])},n(t.haStatus.manager_status||"Unknown"),3)])]),e("div",Js,[s[18]||(s[18]=e("div",{class:"status-label"},"Quorum",-1)),e("div",Xs,[e("span",{class:H(["badge",t.haStatus.quorum?"badge-success":"badge-error"])},n(t.haStatus.quorum?"OK":"Lost"),3)])]),e("div",Zs,[s[19]||(s[19]=e("div",{class:"status-label"},"Total Resources",-1)),e("div",$s,n(t.haResources.length),1)]),e("div",et,[s[20]||(s[20]=e("div",{class:"status-label"},"Total Groups",-1)),e("div",st,n(t.haGroups.length),1)])]))]),s[21]||(s[21]=ae('

    About High Availability

    Proxmox HA allows automatic failover of VMs between cluster nodes. When a node fails, VMs are automatically restarted on other available nodes in the HA group.

    • HA Groups: Define which nodes VMs can run on
    • HA Resources: VMs that are protected by HA
    • Manager Status: Shows if the HA manager service is running

    Configure HA groups and resources using the Proxmox web interface or command line. This page provides read-only monitoring of your HA configuration.

    ',1))]),le(o,{isOpen:t.showWizard,onClose:s[4]||(s[4]=r=>t.showWizard=!1),onComplete:t.onWizardComplete},null,8,["isOpen","onComplete"])])}const it=Q(Hs,[["render",tt],["__scopeId","data-v-1497c293"]]);export{it as default}; diff --git a/src/frontend/dist/assets/HAManagement-BuZVY5gN.css b/src/frontend/dist/assets/HAManagement-BuZVY5gN.css new file mode 100644 index 0000000..0017057 --- /dev/null +++ b/src/frontend/dist/assets/HAManagement-BuZVY5gN.css @@ -0,0 +1 @@ +.wizard-overlay[data-v-4e83599b]{position:fixed;top:0;left:0;right:0;bottom:0;background:#00000080;display:flex;align-items:center;justify-content:center;z-index:9999;padding:2rem}.wizard-modal[data-v-4e83599b]{background:#fff;border-radius:12px;box-shadow:0 20px 60px #0000004d;max-width:800px;width:100%;max-height:90vh;display:flex;flex-direction:column}.wizard-header[data-v-4e83599b]{display:flex;justify-content:space-between;align-items:center;padding:1.5rem 2rem;border-bottom:1px solid #e5e7eb}.wizard-header h2[data-v-4e83599b]{margin:0;font-size:1.5rem;color:#1f2937}.close-btn[data-v-4e83599b]{background:none;border:none;font-size:2rem;color:#9ca3af;cursor:pointer;padding:0;width:2rem;height:2rem;display:flex;align-items:center;justify-content:center;border-radius:4px;transition:all .2s}.close-btn[data-v-4e83599b]:hover{background:#f3f4f6;color:#1f2937}.wizard-steps[data-v-4e83599b]{display:flex;justify-content:space-between;padding:2rem 2rem 1rem;border-bottom:1px solid #e5e7eb;position:relative}.wizard-steps[data-v-4e83599b]:before{content:"";position:absolute;top:2.75rem;left:15%;right:15%;height:2px;background:#e5e7eb;z-index:0}.step[data-v-4e83599b]{display:flex;flex-direction:column;align-items:center;gap:.5rem;flex:1;position:relative;z-index:1}.step-number[data-v-4e83599b]{width:2.5rem;height:2.5rem;border-radius:50%;background:#e5e7eb;color:#6b7280;display:flex;align-items:center;justify-content:center;font-weight:600;transition:all .3s}.step.active .step-number[data-v-4e83599b]{background:#3b82f6;color:#fff}.step.completed .step-number[data-v-4e83599b]{background:#10b981;color:#fff}.step-label[data-v-4e83599b]{font-size:.875rem;color:#6b7280;text-align:center}.step.active .step-label[data-v-4e83599b]{color:#3b82f6;font-weight:600}.wizard-body[data-v-4e83599b]{flex:1;overflow-y:auto;padding:2rem}.wizard-step h3[data-v-4e83599b]{margin:0 0 .5rem;font-size:1.25rem;color:#1f2937}.step-description[data-v-4e83599b]{margin:0 0 1.5rem;color:#6b7280}.loading[data-v-4e83599b]{text-align:center;padding:2rem}.spinner[data-v-4e83599b]{width:3rem;height:3rem;border:3px solid #e5e7eb;border-top-color:#3b82f6;border-radius:50%;animation:spin-4e83599b .8s linear infinite;margin:0 auto 1rem}@keyframes spin-4e83599b{to{transform:rotate(360deg)}}.prerequisite-results[data-v-4e83599b]{display:flex;flex-direction:column;gap:1rem}.check-item[data-v-4e83599b]{display:flex;gap:1rem;padding:1rem;border-radius:8px;border:2px solid #e5e7eb}.check-item.success[data-v-4e83599b]{border-color:#10b981;background:#f0fdf4}.check-item.warning[data-v-4e83599b]{border-color:#f59e0b;background:#fffbeb}.check-item.error[data-v-4e83599b]{border-color:#ef4444;background:#fef2f2}.check-item .icon[data-v-4e83599b]{font-size:1.5rem;font-weight:700}.check-item.success .icon[data-v-4e83599b]{color:#10b981}.check-item.warning .icon[data-v-4e83599b]{color:#f59e0b}.check-item.error .icon[data-v-4e83599b]{color:#ef4444}.check-item strong[data-v-4e83599b]{display:block;margin-bottom:.25rem;color:#1f2937}.check-item p[data-v-4e83599b]{margin:0;font-size:.875rem;color:#6b7280}.form-group[data-v-4e83599b]{margin-bottom:1.5rem}.form-group label[data-v-4e83599b]{display:flex;align-items:center;gap:.5rem;cursor:pointer}.form-label[data-v-4e83599b]{display:block;margin-bottom:.5rem;font-weight:500;color:#374151}.form-control[data-v-4e83599b]{width:100%;padding:.5rem .75rem;border:1px solid #d1d5db;border-radius:6px;font-size:1rem}.form-control[data-v-4e83599b]:focus{outline:none;border-color:#3b82f6;box-shadow:0 0 0 3px #3b82f61a}.help-text[data-v-4e83599b]{display:block;margin-top:.25rem;font-size:.875rem;color:#6b7280}.new-group-form[data-v-4e83599b]{padding:1rem;background:#f9fafb;border-radius:8px;margin-top:1rem}.vm-list[data-v-4e83599b]{display:flex;flex-direction:column;gap:.5rem;max-height:400px;overflow-y:auto}.vm-item[data-v-4e83599b]{border:1px solid #e5e7eb;border-radius:6px;transition:all .2s}.vm-item[data-v-4e83599b]:hover{border-color:#3b82f6;background:#f9fafb}.vm-checkbox[data-v-4e83599b]{display:flex;align-items:center;gap:1rem;padding:1rem;cursor:pointer}.vm-checkbox input[type=checkbox][data-v-4e83599b]{width:1.25rem;height:1.25rem;cursor:pointer}.vm-info[data-v-4e83599b]{flex:1;display:flex;flex-direction:column;gap:.25rem}.vm-details[data-v-4e83599b]{font-size:.875rem;color:#6b7280}.vm-status[data-v-4e83599b]{font-size:.75rem;padding:.125rem .5rem;border-radius:9999px;background:#e5e7eb;color:#4b5563;align-self:flex-start}.vm-status.running[data-v-4e83599b]{background:#d1fae5;color:#065f46}.selection-summary[data-v-4e83599b]{margin-top:1rem;padding:1rem;background:#eff6ff;border-radius:6px;text-align:center;color:#1e40af}.empty-state[data-v-4e83599b]{text-align:center;padding:3rem 2rem;color:#6b7280}.review-section[data-v-4e83599b]{margin-bottom:2rem}.review-section h4[data-v-4e83599b]{margin:0 0 1rem;font-size:1rem;color:#1f2937;font-weight:600;border-bottom:2px solid #e5e7eb;padding-bottom:.5rem}.review-item[data-v-4e83599b]{display:flex;gap:1rem;padding:.5rem 0;border-bottom:1px solid #f3f4f6}.review-item strong[data-v-4e83599b]{min-width:150px;color:#6b7280}.success-message[data-v-4e83599b]{padding:1rem;background:#f0fdf4;border:2px solid #10b981;border-radius:8px;color:#065f46}.success-message .icon[data-v-4e83599b]{font-size:1.5rem;color:#10b981;margin-right:.5rem}.success-message ul[data-v-4e83599b]{margin:1rem 0 0;padding-left:1.5rem}.error-message[data-v-4e83599b]{padding:1rem;background:#fef2f2;border:2px solid #ef4444;border-radius:8px;color:#991b1b}.error-message .icon[data-v-4e83599b]{font-size:1.5rem;color:#ef4444;margin-right:.5rem}.wizard-footer[data-v-4e83599b]{display:flex;justify-content:flex-end;gap:1rem;padding:1.5rem 2rem;border-top:1px solid #e5e7eb}.btn[data-v-4e83599b]{padding:.625rem 1.25rem;border-radius:6px;border:none;font-weight:500;cursor:pointer;transition:all .2s;font-size:1rem}.btn[data-v-4e83599b]:disabled{opacity:.5;cursor:not-allowed}.btn-primary[data-v-4e83599b]{background:#3b82f6;color:#fff}.btn-primary[data-v-4e83599b]:hover:not(:disabled){background:#2563eb}.btn-secondary[data-v-4e83599b]{background:#e5e7eb;color:#374151}.btn-secondary[data-v-4e83599b]:hover:not(:disabled){background:#d1d5db}.btn-success[data-v-4e83599b]{background:#10b981;color:#fff}.btn-success[data-v-4e83599b]:hover:not(:disabled){background:#059669}.node-selection[data-v-4e83599b]{display:flex;flex-direction:column;gap:.5rem;max-height:300px;overflow-y:auto;padding:.5rem;border:1px solid #e5e7eb;border-radius:6px;background:#f9fafb}.node-checkbox[data-v-4e83599b]{display:flex;align-items:center;gap:1rem;padding:.75rem;cursor:pointer;border-radius:4px;background:#fff;border:1px solid #e5e7eb;transition:all .2s}.node-checkbox[data-v-4e83599b]:hover{border-color:#3b82f6;background:#f0f9ff}.node-checkbox input[type=checkbox][data-v-4e83599b]{width:1.25rem;height:1.25rem;cursor:pointer}.node-info[data-v-4e83599b]{flex:1;display:flex;align-items:center;justify-content:space-between}.node-status[data-v-4e83599b]{font-size:.75rem;padding:.125rem .5rem;border-radius:9999px;background:#e5e7eb;color:#4b5563}.node-status.online[data-v-4e83599b]{background:#d1fae5;color:#065f46}.loading-inline[data-v-4e83599b]{display:flex;align-items:center;gap:.5rem;padding:1rem;color:#6b7280}.spinner-small[data-v-4e83599b]{width:1rem;height:1rem;border:2px solid #e5e7eb;border-top-color:#3b82f6;border-radius:50%;animation:spin-4e83599b .8s linear infinite}.empty-state-inline[data-v-4e83599b]{padding:1rem;text-align:center;color:#6b7280;background:#f9fafb;border-radius:6px}.info-message[data-v-4e83599b]{padding:1rem;background:#eff6ff;border:2px solid #3b82f6;border-radius:8px;color:#1e40af;display:flex;gap:.5rem;align-items:flex-start;margin-bottom:1rem}.info-message .icon[data-v-4e83599b]{font-size:1.25rem}.info-message p[data-v-4e83599b]{margin:0;flex:1}.ha-management[data-v-1497c293]{padding:2rem;max-width:1400px;margin:0 auto}.page-header[data-v-1497c293]{margin-bottom:2rem;display:flex;justify-content:space-between;align-items:center}.page-header h1[data-v-1497c293]{font-size:2rem;font-weight:700;margin-bottom:.5rem}.subtitle[data-v-1497c293]{color:#6b7280;margin:0}.btn-wizard[data-v-1497c293]{white-space:nowrap;font-size:1rem;padding:.75rem 1.5rem}.btn-primary[data-v-1497c293]{background:#3b82f6;color:#fff;border:none;border-radius:6px;cursor:pointer;font-weight:500;transition:all .2s}.btn-primary[data-v-1497c293]:hover{background:#2563eb;transform:translateY(-1px);box-shadow:0 4px 12px #3b82f64d}.ha-content[data-v-1497c293]{display:flex;flex-direction:column;gap:1.5rem}.card[data-v-1497c293]{background:#fff;border-radius:8px;padding:1.5rem;box-shadow:0 1px 3px #0000001a}.card-header[data-v-1497c293]{display:flex;justify-content:space-between;align-items:center;margin-bottom:1.5rem}.card-header h2[data-v-1497c293]{font-size:1.25rem;font-weight:600;margin:0}.loading[data-v-1497c293]{text-align:center;padding:2rem;color:#6b7280}.error-message[data-v-1497c293]{padding:1rem;background:#fef2f2;border:1px solid #fecaca;border-radius:6px;color:#dc2626}.empty-state[data-v-1497c293]{text-align:center;padding:3rem 2rem;color:#6b7280}.empty-state p[data-v-1497c293]{margin:.5rem 0}.help-text[data-v-1497c293]{font-size:.875rem;color:#9ca3af}.table-responsive[data-v-1497c293]{overflow-x:auto}.data-table[data-v-1497c293]{width:100%;border-collapse:collapse}.data-table thead[data-v-1497c293]{background:#f9fafb}.data-table th[data-v-1497c293],.data-table td[data-v-1497c293]{padding:.75rem 1rem;text-align:left;border-bottom:1px solid #e5e7eb}.data-table th[data-v-1497c293]{font-weight:600;font-size:.875rem;color:#6b7280;text-transform:uppercase}.data-table tbody tr[data-v-1497c293]:hover{background:#f9fafb}.badge[data-v-1497c293]{display:inline-block;padding:.25rem .75rem;border-radius:9999px;font-size:.75rem;font-weight:500}.badge-success[data-v-1497c293]{background:#d1fae5;color:#065f46}.badge-error[data-v-1497c293]{background:#fee2e2;color:#991b1b}.badge-warning[data-v-1497c293]{background:#fef3c7;color:#92400e}.badge-secondary[data-v-1497c293]{background:#e5e7eb;color:#4b5563}.status-grid[data-v-1497c293]{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:1.5rem}.status-item[data-v-1497c293]{text-align:center}.status-label[data-v-1497c293]{font-size:.875rem;color:#6b7280;margin-bottom:.5rem}.status-value[data-v-1497c293]{font-size:1.5rem;font-weight:600}.help-card[data-v-1497c293]{background:#f0f9ff;border:1px solid #bae6fd}.help-card h3[data-v-1497c293]{margin-top:0;color:#075985}.help-card ul[data-v-1497c293]{margin:1rem 0;padding-left:1.5rem}.help-card li[data-v-1497c293]{margin:.5rem 0}.btn[data-v-1497c293]{padding:.5rem 1rem;border-radius:6px;border:none;font-weight:500;cursor:pointer;display:inline-flex;align-items:center;gap:.5rem;transition:all .2s}.btn-secondary[data-v-1497c293]{background:#f3f4f6;color:#374151}.btn-secondary[data-v-1497c293]:hover{background:#e5e7eb}.icon[data-v-1497c293]{font-size:1rem} diff --git a/src/frontend/dist/assets/ISOImages-B9gk_FEk.css b/src/frontend/dist/assets/ISOImages-B9gk_FEk.css new file mode 100644 index 0000000..12b4fea --- /dev/null +++ b/src/frontend/dist/assets/ISOImages-B9gk_FEk.css @@ -0,0 +1 @@ +.btn-sm[data-v-295eeb95]{padding:.25rem .5rem;font-size:.875rem}.modal[data-v-295eeb95]{position:fixed;top:0;left:0;right:0;bottom:0;background-color:#00000080;display:flex;align-items:center;justify-content:center;z-index:1000}.modal-content[data-v-295eeb95]{background:#fff;border-radius:.5rem;max-width:600px;width:90%;max-height:90vh;overflow-y:auto;box-shadow:var(--shadow-lg)}.modal-header[data-v-295eeb95]{padding:1.5rem;border-bottom:1px solid var(--border-color);display:flex;justify-content:space-between;align-items:center}.modal-header h3[data-v-295eeb95]{margin:0}.btn-close[data-v-295eeb95]{background:none;border:none;font-size:2rem;cursor:pointer;color:var(--text-secondary);line-height:1}.modal-body[data-v-295eeb95]{padding:1.5rem}.upload-progress[data-v-295eeb95]{margin-top:1rem;padding:1rem;background-color:var(--background);border-radius:.5rem;border:1px solid var(--border-color)}.upload-stats[data-v-295eeb95]{margin-bottom:1rem}.stat-row[data-v-295eeb95]{display:flex;justify-content:space-between;align-items:center;padding:.5rem 0;border-bottom:1px solid var(--border-color)}.stat-row[data-v-295eeb95]:last-child{border-bottom:none}.stat-label[data-v-295eeb95]{font-size:.875rem;color:var(--text-secondary);font-weight:500}.stat-value[data-v-295eeb95]{font-size:.875rem;color:var(--text-primary);font-weight:600;font-family:monospace}.progress-bar[data-v-295eeb95]{height:1.25rem;background-color:var(--border-color);border-radius:9999px;overflow:hidden;position:relative;box-shadow:inset 0 2px 4px #0000001a}.progress-fill[data-v-295eeb95]{height:100%;background:linear-gradient(90deg,var(--primary-color),var(--secondary-color));transition:width .2s ease;box-shadow:0 0 10px #2563eb80} diff --git a/src/frontend/dist/assets/ISOImages-BihK9fce.js b/src/frontend/dist/assets/ISOImages-BihK9fce.js new file mode 100644 index 0000000..4ad39fd --- /dev/null +++ b/src/frontend/dist/assets/ISOImages-BihK9fce.js @@ -0,0 +1 @@ +import{_ as G,c as d,o as r,a as o,d as w,F as X,p as K,t as n,m as j,w as I,e as m,v as h,A as U,B as N,n as H,x as J,r as u,k as Q,l as y}from"./index-DpP9cWht.js";const Y={name:"ISOImages",setup(){const p=J(),e=u([]),k=u(!1),t=u(!1),x=u(!1),c=u(0),a=u(0),f=u(0),g=u(0),D=u(!1),B=u(!1),F=u(null);let V=null;const b=u({name:"",os_type:"",version:"",architecture:"amd64"}),M=u({url:"",name:"",os_type:"",version:"",architecture:"amd64"}),_=async()=>{k.value=!0;try{const l=await y.isos.list();e.value=l.data}catch(l){console.error("Failed to fetch ISOs:",l)}finally{k.value=!1}},P=l=>{const s=l.target.files[0];s&&(F.value=s,b.value.name||(b.value.name=s.name.replace(".iso","")))},A=async()=>{if(F.value){t.value=!0,c.value=0,V=Date.now();try{const l=new FormData;l.append("file",F.value),l.append("name",b.value.name),l.append("os_type",b.value.os_type),l.append("version",b.value.version),l.append("architecture",b.value.architecture);const s=i=>{if(i.total){const S=Math.round(i.loaded*50/i.total);c.value=S,a.value=i.loaded,f.value=i.total;const T=(Date.now()-V)/1e3;T>0&&(g.value=i.loaded/T)}},O=(await y.isos.upload(l,s)).data.id;p.info("Upload complete, processing file...",{timeout:2e3});let C=!1;for(;!C;){await new Promise(i=>setTimeout(i,500));try{const S=(await y.isos.getProgress(O)).data;if(S.status==="completed")c.value=100,C=!0,p.success("ISO uploaded and processed successfully");else{if(S.status==="error")throw new Error(S.message||"Processing failed");c.value=50+Math.round(S.progress/2)}}catch(i){console.warn("Failed to get progress, assuming complete:",i),C=!0,c.value=100}}await new Promise(i=>setTimeout(i,500)),D.value=!1,b.value={name:"",os_type:"",version:"",architecture:"amd64"},F.value=null,a.value=0,f.value=0,g.value=0,await _()}catch(l){console.error("Failed to upload ISO:",l),p.error("Failed to upload ISO: "+(l.message||"Unknown error"))}finally{t.value=!1,c.value=0,a.value=0,f.value=0,g.value=0}}},W=async l=>{const s=p.info("Checking ISO checksum...",{timeout:!1});try{const v=await y.isos.verify(l);p.dismiss(s),v.data.status==="valid"?p.success("ISO checksum is valid"):p.error("ISO checksum verification failed")}catch(v){p.dismiss(s),console.error("Failed to verify ISO:",v)}},L=async l=>{if(confirm("Are you sure you want to delete this ISO image?"))try{await y.isos.delete(l),p.success("ISO deleted successfully"),await _()}catch(s){console.error("Failed to delete ISO:",s)}},R=l=>({ubuntu:"Ubuntu",debian:"Debian",centos:"CentOS",rocky:"Rocky Linux",alma:"AlmaLinux",windows_server_2019:"Windows Server 2019",windows_server_2022:"Windows Server 2022",windows_10:"Windows 10",windows_11:"Windows 11"})[l]||l,q=l=>{if(!l)return"0 B";const s=["B","KB","MB","GB","TB"],v=Math.floor(Math.log(l)/Math.log(1024));return Math.round(l/Math.pow(1024,v)*100)/100+" "+s[v]},z=()=>{if(g.value===0||a.value===0)return"Calculating...";const s=(f.value-a.value)/g.value;if(s<60)return Math.ceil(s)+"s";if(s<3600){const v=Math.floor(s/60),O=Math.ceil(s%60);return`${v}m ${O}s`}else{const v=Math.floor(s/3600),O=Math.ceil(s%3600/60);return`${v}h ${O}m`}},E=async()=>{x.value=!0;try{await y.isos.downloadFromUrl(M.value),p.success("ISO download started successfully"),B.value=!1,M.value={url:"",name:"",os_type:"",version:"",architecture:"amd64"},await _()}catch(l){console.error("Failed to download ISO:",l)}finally{x.value=!1}};return Q(()=>{_()}),{isos:e,loading:k,uploading:t,downloading:x,uploadProgress:c,uploadedBytes:a,totalBytes:f,uploadSpeed:g,showUploadModal:D,showDownloadModal:B,selectedFile:F,uploadForm:b,downloadForm:M,onFileSelected:P,uploadISO:A,downloadFromUrl:E,verifyISO:W,deleteISO:L,formatOSType:R,formatBytes:q,formatTimeRemaining:z}}},Z={class:"iso-images-page"},$={class:"card"},oo={class:"card-header"},eo={class:"flex gap-1"},to={key:0,class:"loading-spinner"},ao={key:1,class:"text-center text-muted"},lo={key:2,class:"table-container"},so={class:"table"},no={class:"badge badge-info"},io={class:"text-xs text-muted"},ro={class:"flex gap-1"},uo=["onClick"],po=["onClick"],vo={class:"modal-header"},mo={class:"form-group"},co={key:0,class:"text-sm text-muted mt-1"},bo={class:"form-group"},fo={class:"form-group"},wo={class:"grid grid-cols-2 gap-2"},go={class:"form-group"},So={class:"form-group"},yo={key:0,class:"upload-progress"},Fo={class:"upload-stats"},Oo={class:"stat-row"},ho={class:"stat-value"},ko={class:"stat-row"},xo={class:"stat-value"},_o={key:0,class:"stat-row"},Io={class:"stat-value"},Uo={key:1,class:"stat-row"},Mo={class:"stat-value"},Co={class:"progress-bar"},Do={class:"flex gap-1 mt-2"},Bo=["disabled"],Vo=["disabled"],To={class:"modal-header"},No={class:"form-group"},Po={class:"form-group"},Ao={class:"form-group"},Wo={class:"grid grid-cols-2 gap-2"},Lo={class:"form-group"},Ro={class:"form-group"},qo={key:0,class:"upload-progress"},zo={class:"flex gap-1 mt-2"},Eo=["disabled"],Go=["disabled"];function Xo(p,e,k,t,x,c){return r(),d("div",Z,[o("div",$,[o("div",oo,[e[22]||(e[22]=o("h3",null,"ISO Images",-1)),o("div",eo,[o("button",{onClick:e[0]||(e[0]=a=>t.showUploadModal=!0),class:"btn btn-primary"},"+ Upload ISO"),o("button",{onClick:e[1]||(e[1]=a=>t.showDownloadModal=!0),class:"btn btn-outline"},"Download from URL")])]),t.loading?(r(),d("div",to)):t.isos.length===0?(r(),d("div",ao,[...e[23]||(e[23]=[o("p",null,"No ISO images uploaded yet.",-1),o("p",{class:"text-sm"},"Upload ISOs to deploy VMs.",-1)])])):(r(),d("div",lo,[o("table",so,[e[24]||(e[24]=o("thead",null,[o("tr",null,[o("th",null,"Name"),o("th",null,"OS Type"),o("th",null,"Version"),o("th",null,"Architecture"),o("th",null,"Size"),o("th",null,"Checksum"),o("th",null,"Status"),o("th",null,"Actions")])],-1)),o("tbody",null,[(r(!0),d(X,null,K(t.isos,a=>(r(),d("tr",{key:a.id},[o("td",null,n(a.name),1),o("td",null,[o("span",no,n(t.formatOSType(a.os_type)),1)]),o("td",null,n(a.version||"N/A"),1),o("td",null,n(a.architecture),1),o("td",null,n(t.formatBytes(a.file_size)),1),o("td",io,n(a.checksum?a.checksum.substring(0,16)+"...":"N/A"),1),o("td",null,[o("span",{class:j(["badge",a.is_available?"badge-success":"badge-danger"])},n(a.is_available?"Available":"Unavailable"),3)]),o("td",null,[o("div",ro,[o("button",{onClick:f=>t.verifyISO(a.id),class:"btn btn-outline btn-sm"},"Verify",8,uo),o("button",{onClick:f=>t.deleteISO(a.id),class:"btn btn-danger btn-sm"},"Delete",8,po)])])]))),128))])])]))]),t.showUploadModal?(r(),d("div",{key:0,class:"modal",onClick:e[11]||(e[11]=a=>t.showUploadModal=!1)},[o("div",{class:"modal-content",onClick:e[10]||(e[10]=I(()=>{},["stop"]))},[o("div",vo,[e[25]||(e[25]=o("h3",null,"Upload ISO Image",-1)),o("button",{onClick:e[2]||(e[2]=a=>t.showUploadModal=!1),class:"btn-close"},"×")]),o("form",{onSubmit:e[9]||(e[9]=I((...a)=>t.uploadISO&&t.uploadISO(...a),["prevent"])),class:"modal-body"},[o("div",mo,[e[26]||(e[26]=o("label",{class:"form-label"},"ISO File *",-1)),o("input",{type:"file",onChange:e[3]||(e[3]=(...a)=>t.onFileSelected&&t.onFileSelected(...a)),accept:".iso",class:"form-control",required:""},null,32),t.selectedFile?(r(),d("p",co," Selected: "+n(t.selectedFile.name)+" ("+n(t.formatBytes(t.selectedFile.size))+") ",1)):w("",!0)]),o("div",bo,[e[27]||(e[27]=o("label",{class:"form-label"},"Display Name *",-1)),m(o("input",{"onUpdate:modelValue":e[4]||(e[4]=a=>t.uploadForm.name=a),class:"form-control",required:""},null,512),[[h,t.uploadForm.name]])]),o("div",fo,[e[29]||(e[29]=o("label",{class:"form-label"},"OS Type *",-1)),m(o("select",{"onUpdate:modelValue":e[5]||(e[5]=a=>t.uploadForm.os_type=a),class:"form-control",required:""},[...e[28]||(e[28]=[N('',5)])],512),[[U,t.uploadForm.os_type]])]),o("div",wo,[o("div",go,[e[30]||(e[30]=o("label",{class:"form-label"},"Version",-1)),m(o("input",{"onUpdate:modelValue":e[6]||(e[6]=a=>t.uploadForm.version=a),class:"form-control",placeholder:"22.04"},null,512),[[h,t.uploadForm.version]])]),o("div",So,[e[32]||(e[32]=o("label",{class:"form-label"},"Architecture",-1)),m(o("select",{"onUpdate:modelValue":e[7]||(e[7]=a=>t.uploadForm.architecture=a),class:"form-control"},[...e[31]||(e[31]=[o("option",{value:"amd64"},"amd64 (x86_64)",-1),o("option",{value:"arm64"},"arm64",-1)])],512),[[U,t.uploadForm.architecture]])])]),t.uploading?(r(),d("div",yo,[o("div",Fo,[o("div",Oo,[e[33]||(e[33]=o("span",{class:"stat-label"},"Progress:",-1)),o("span",ho,n(t.uploadProgress)+"%",1)]),o("div",ko,[e[34]||(e[34]=o("span",{class:"stat-label"},"Transferred:",-1)),o("span",xo,n(t.formatBytes(t.uploadedBytes))+" / "+n(t.formatBytes(t.totalBytes)),1)]),t.uploadSpeed>0?(r(),d("div",_o,[e[35]||(e[35]=o("span",{class:"stat-label"},"Speed:",-1)),o("span",Io,n(t.formatBytes(t.uploadSpeed))+"/s",1)])):w("",!0),t.uploadSpeed>0&&t.uploadProgress<100?(r(),d("div",Uo,[e[36]||(e[36]=o("span",{class:"stat-label"},"Time remaining:",-1)),o("span",Mo,n(t.formatTimeRemaining()),1)])):w("",!0)]),o("div",Co,[o("div",{class:"progress-fill",style:H({width:t.uploadProgress+"%"})},null,4)])])):w("",!0),o("div",Do,[o("button",{type:"submit",class:"btn btn-primary",disabled:t.uploading||!t.selectedFile},n(t.uploading?"Uploading...":"Upload ISO"),9,Bo),o("button",{type:"button",onClick:e[8]||(e[8]=a=>t.showUploadModal=!1),class:"btn btn-outline",disabled:t.uploading}," Cancel ",8,Vo)])],32)])])):w("",!0),t.showDownloadModal?(r(),d("div",{key:1,class:"modal",onClick:e[21]||(e[21]=a=>t.showDownloadModal=!1)},[o("div",{class:"modal-content",onClick:e[20]||(e[20]=I(()=>{},["stop"]))},[o("div",To,[e[37]||(e[37]=o("h3",null,"Download ISO from URL",-1)),o("button",{onClick:e[12]||(e[12]=a=>t.showDownloadModal=!1),class:"btn-close"},"×")]),o("form",{onSubmit:e[19]||(e[19]=I((...a)=>t.downloadFromUrl&&t.downloadFromUrl(...a),["prevent"])),class:"modal-body"},[o("div",No,[e[38]||(e[38]=o("label",{class:"form-label"},"ISO URL *",-1)),m(o("input",{"onUpdate:modelValue":e[13]||(e[13]=a=>t.downloadForm.url=a),type:"url",class:"form-control",placeholder:"https://example.com/path/to/image.iso",required:""},null,512),[[h,t.downloadForm.url]]),e[39]||(e[39]=o("p",{class:"text-xs text-muted mt-1"},"Direct link to the ISO file",-1))]),o("div",Po,[e[40]||(e[40]=o("label",{class:"form-label"},"Display Name *",-1)),m(o("input",{"onUpdate:modelValue":e[14]||(e[14]=a=>t.downloadForm.name=a),class:"form-control",required:""},null,512),[[h,t.downloadForm.name]])]),o("div",Ao,[e[42]||(e[42]=o("label",{class:"form-label"},"OS Type *",-1)),m(o("select",{"onUpdate:modelValue":e[15]||(e[15]=a=>t.downloadForm.os_type=a),class:"form-control",required:""},[...e[41]||(e[41]=[N('',5)])],512),[[U,t.downloadForm.os_type]])]),o("div",Wo,[o("div",Lo,[e[43]||(e[43]=o("label",{class:"form-label"},"Version",-1)),m(o("input",{"onUpdate:modelValue":e[16]||(e[16]=a=>t.downloadForm.version=a),class:"form-control",placeholder:"22.04"},null,512),[[h,t.downloadForm.version]])]),o("div",Ro,[e[45]||(e[45]=o("label",{class:"form-label"},"Architecture",-1)),m(o("select",{"onUpdate:modelValue":e[17]||(e[17]=a=>t.downloadForm.architecture=a),class:"form-control"},[...e[44]||(e[44]=[o("option",{value:"amd64"},"amd64 (x86_64)",-1),o("option",{value:"arm64"},"arm64",-1)])],512),[[U,t.downloadForm.architecture]])])]),t.downloading?(r(),d("div",qo,[...e[46]||(e[46]=[o("p",{class:"text-center text-muted"},"Downloading ISO from URL...",-1),o("div",{class:"loading-spinner"},null,-1)])])):w("",!0),o("div",zo,[o("button",{type:"submit",class:"btn btn-primary",disabled:t.downloading},n(t.downloading?"Downloading...":"Download ISO"),9,Eo),o("button",{type:"button",onClick:e[18]||(e[18]=a=>t.showDownloadModal=!1),class:"btn btn-outline",disabled:t.downloading}," Cancel ",8,Go)])],32)])])):w("",!0)])}const jo=G(Y,[["render",Xo],["__scopeId","data-v-295eeb95"]]);export{jo as default}; diff --git a/src/frontend/dist/assets/Login-C73ePELU.js b/src/frontend/dist/assets/Login-C73ePELU.js new file mode 100644 index 0000000..d00d891 --- /dev/null +++ b/src/frontend/dist/assets/Login-C73ePELU.js @@ -0,0 +1 @@ +import{_ as f,c,o as u,a as e,b as m,w as g,d as b,e as n,v as a,t as v,u as w,f as y,r as i}from"./index-DpP9cWht.js";const x={name:"Login",setup(){const d=w(),o=y(),r=i({username:"",password:"",totp_code:""}),s=i(!1),l=i(!1);return{credentials:r,loading:s,show2FA:l,handleLogin:async()=>{s.value=!0;try{const t=await o.login(r.value);t.success?d.push("/"):t.error==="2FA code required"&&(l.value=!0)}catch(t){console.error("Login error:",t)}finally{s.value=!1}}}}},L={class:"login-container"},V={class:"login-card"},_={class:"form-group"},h={class:"form-group"},A={key:0,class:"form-group"},D=["disabled"];function S(d,o,r,s,l,p){return u(),c("div",L,[e("div",V,[o[7]||(o[7]=e("div",{class:"login-header"},[e("h1",{class:"login-logo"},[m("Depl"),e("span",{class:"logo-zero"},"0"),m("y")]),e("p",{class:"login-subtitle"},"VM Deployment Panel")],-1)),e("form",{onSubmit:o[3]||(o[3]=g((...t)=>s.handleLogin&&s.handleLogin(...t),["prevent"])),class:"login-form"},[e("div",_,[o[4]||(o[4]=e("label",{for:"username",class:"form-label"},"Username",-1)),n(e("input",{id:"username","onUpdate:modelValue":o[0]||(o[0]=t=>s.credentials.username=t),type:"text",class:"form-control",required:"",autofocus:""},null,512),[[a,s.credentials.username]])]),e("div",h,[o[5]||(o[5]=e("label",{for:"password",class:"form-label"},"Password",-1)),n(e("input",{id:"password","onUpdate:modelValue":o[1]||(o[1]=t=>s.credentials.password=t),type:"password",class:"form-control",required:""},null,512),[[a,s.credentials.password]])]),s.show2FA?(u(),c("div",A,[o[6]||(o[6]=e("label",{for:"totp",class:"form-label"},"2FA Code",-1)),n(e("input",{id:"totp","onUpdate:modelValue":o[2]||(o[2]=t=>s.credentials.totp_code=t),type:"text",class:"form-control",placeholder:"000000",maxlength:"6"},null,512),[[a,s.credentials.totp_code]])])):b("",!0),e("button",{type:"submit",class:"btn btn-primary btn-block",disabled:s.loading},v(s.loading?"Logging in...":"Login"),9,D)],32),o[8]||(o[8]=e("div",{class:"login-footer"},[e("p",{class:"text-muted text-sm text-center"}," Open Source VM Deployment Platform ")],-1))])])}const F=f(x,[["render",S],["__scopeId","data-v-bf325ce1"]]);export{F as default}; diff --git a/src/frontend/dist/assets/Login-CoaePlAS.css b/src/frontend/dist/assets/Login-CoaePlAS.css new file mode 100644 index 0000000..c888520 --- /dev/null +++ b/src/frontend/dist/assets/Login-CoaePlAS.css @@ -0,0 +1 @@ +.login-container[data-v-bf325ce1]{display:flex;align-items:center;justify-content:center;min-height:100vh;background:linear-gradient(135deg,#1e3a8a,#1e40af,#1d4ed8);padding:2rem}.login-card[data-v-bf325ce1]{background:#fff;border-radius:1rem;box-shadow:0 20px 25px -5px #0000001a;width:100%;max-width:400px;padding:2rem}.login-header[data-v-bf325ce1]{text-align:center;margin-bottom:2rem}.login-logo[data-v-bf325ce1]{font-size:3rem;font-weight:700;margin:0;color:var(--text-primary)}.logo-zero[data-v-bf325ce1]{color:#3b82f6}.login-subtitle[data-v-bf325ce1]{color:var(--text-secondary);margin-top:.5rem}.login-form[data-v-bf325ce1]{margin-bottom:1.5rem}.btn-block[data-v-bf325ce1]{width:100%;margin-top:1rem}.login-footer[data-v-bf325ce1]{margin-top:2rem;padding-top:1rem;border-top:1px solid var(--border-color)} diff --git a/src/frontend/dist/assets/NotFound-W1QtGFtv.js b/src/frontend/dist/assets/NotFound-W1QtGFtv.js new file mode 100644 index 0000000..26e277a --- /dev/null +++ b/src/frontend/dist/assets/NotFound-W1QtGFtv.js @@ -0,0 +1 @@ +import{_ as t,c as s,o as a,a as e}from"./index-DpP9cWht.js";const n={name:"NotFound"},d={class:"notfound-page"};function c(r,o,l,p,u,i){return a(),s("div",d,[...o[0]||(o[0]=[e("div",{class:"card"},[e("div",{class:"card-header"},[e("h3",null,"NotFound")]),e("p",{class:"text-muted"},"NotFound implementation goes here")],-1)])])}const m=t(n,[["render",c]]);export{m as default}; diff --git a/src/frontend/dist/assets/ProxmoxHosts-ShY9t_Gh.js b/src/frontend/dist/assets/ProxmoxHosts-ShY9t_Gh.js new file mode 100644 index 0000000..3597867 --- /dev/null +++ b/src/frontend/dist/assets/ProxmoxHosts-ShY9t_Gh.js @@ -0,0 +1 @@ +import{_ as L,c as l,o as a,a as s,d as P,F as H,p as A,t as d,m as C,w as N,e as m,v as u,b as g,E as M,x as W,r as c,j as E,k as j,l as f}from"./index-DpP9cWht.js";const R={name:"ProxmoxHosts",setup(){const x=W(),t=c([]),b=c([]),o=c(!1),y=c(!1),_=c(!1),e=c(!1),r=c(!1),h=c({name:"",hostname:"",port:8006,username:"root@pam",password:"",api_token_id:"",api_token_secret:"",verify_ssl:!1}),k=async()=>{o.value=!0;try{const n=await f.proxmox.listHosts();t.value=n.data}catch(n){console.error("Failed to fetch hosts:",n)}finally{o.value=!1}},T=async()=>{_.value=!0;try{const n={...h.value};r.value?delete n.password:(delete n.api_token_id,delete n.api_token_secret),await f.proxmox.createHost(n),x.success("Proxmox host added successfully"),e.value=!1,r.value=!1,h.value={name:"",hostname:"",port:8006,username:"root@pam",password:"",api_token_id:"",api_token_secret:"",verify_ssl:!1},await k()}catch(n){console.error("Failed to add host:",n)}finally{_.value=!1}},U=async n=>{try{const i=await f.proxmox.testConnection(n);i.data.status==="success"?x.success("Connection successful!"):x.error("Connection failed: "+i.data.message)}catch(i){console.error("Failed to test connection:",i)}},D=async n=>{try{await f.proxmox.pollHost(n),x.success("Polling started"),setTimeout(()=>{k(),w()},2e3)}catch(i){console.error("Failed to poll host:",i)}},w=async()=>{y.value=!0;try{const n=t.value.map(p=>f.proxmox.listNodes(p.id).catch(v=>(console.error(`Failed to fetch nodes for ${p.name}:`,v),{data:[]}))),i=await Promise.all(n);b.value=i.flatMap((p,v)=>p.data.map(q=>({...q,host_id:t.value[v].id})))}catch(n){console.error("Failed to refresh nodes:",n)}finally{y.value=!1}},V=E(()=>t.value.map(n=>({...n,nodes:b.value.filter(i=>i.host_id===n.id)}))),F=n=>{if(!n)return"N/A";const i=Math.floor(n/86400),p=Math.floor(n%86400/3600),v=Math.floor(n%3600/60);return`${i}d ${p}h ${v}m`},B=async n=>{if(confirm("Are you sure you want to delete this Proxmox host?"))try{await f.proxmox.deleteHost(n),x.success("Host deleted"),await k()}catch(i){console.error("Failed to delete host:",i)}},I=n=>new Date(n).toLocaleString(),S=n=>n?(n/(1024*1024*1024)).toFixed(2)+" GB":"0 B";return j(()=>{k().then(()=>{w()})}),{hosts:t,allNodes:b,loading:o,loadingNodes:y,saving:_,showAddModal:e,useApiToken:r,newHost:h,datacentersWithNodes:V,addHost:T,testConnection:U,pollHost:D,refreshAllNodes:w,deleteHost:B,formatDate:I,formatBytes:S,formatUptime:F}}},z={class:"proxmox-hosts-page"},G={class:"card mb-2"},K={class:"card-header"},O={key:0,class:"loading-spinner"},J={key:1,class:"text-center text-muted"},Q={key:2,class:"table-container"},X={class:"table"},Y={key:0,class:"text-sm"},Z={key:1,class:"text-muted text-sm"},$={class:"flex gap-1"},ss=["onClick"],ts=["onClick"],os=["onClick"],es={class:"card"},ns={class:"card-header"},ls={key:0},as={key:1},rs={key:0,class:"loading-spinner"},ds={key:1,class:"text-center text-muted"},is={key:2,class:"nodes-section"},ms={class:"datacenter-title"},cs={key:0,class:"text-muted text-sm"},us={key:1,class:"nodes-grid"},xs={class:"node-header"},ps={class:"node-stats"},fs={class:"stat"},vs={class:"stat-value"},bs={class:"stat"},ys={class:"stat-value"},_s={class:"stat"},ks={class:"stat-value"},gs={class:"stat"},hs={class:"stat-value"},ws={class:"modal-header"},Hs={class:"form-group"},As={class:"form-group"},Ps={class:"form-group"},Cs={class:"form-group"},Ns={class:"auth-section"},Ms={class:"form-group"},Ts={class:"form-label"},Us={key:0,class:"warning-box"},Ds={key:1},Vs={class:"form-group"},Fs=["required"],Bs={key:2},Is={class:"form-group"},Ss=["required"],qs={class:"form-group"},Ls=["required"],Ws={class:"form-group"},Es={class:"form-label"},js={class:"flex gap-1 mt-2"},Rs=["disabled"];function zs(x,t,b,o,y,_){return a(),l("div",z,[t[40]||(t[40]=s("div",{class:"page-header mb-2"},[s("h2",null,"Proxmox Datacenters and Hosts"),s("p",{class:"text-muted"},"Manage your Proxmox clusters and hypervisor nodes")],-1)),s("div",G,[s("div",K,[t[16]||(t[16]=s("h3",null,"Datacenters",-1)),s("button",{onClick:t[0]||(t[0]=e=>o.showAddModal=!0),class:"btn btn-primary"},"+ Add Datacenter")]),o.loading?(a(),l("div",O)):o.hosts.length===0?(a(),l("div",J,[...t[17]||(t[17]=[s("p",null,"No Proxmox datacenters configured yet.",-1),s("p",{class:"text-sm"},"Add a datacenter to start deploying VMs.",-1)])])):(a(),l("div",Q,[s("table",X,[t[18]||(t[18]=s("thead",null,[s("tr",null,[s("th",null,"Datacenter Name"),s("th",null,"Hostname"),s("th",null,"Username"),s("th",null,"Status"),s("th",null,"Last Poll"),s("th",null,"Actions")])],-1)),s("tbody",null,[(a(!0),l(H,null,A(o.hosts,e=>(a(),l("tr",{key:e.id},[s("td",null,d(e.name),1),s("td",null,d(e.hostname)+":"+d(e.port),1),s("td",null,d(e.username),1),s("td",null,[s("span",{class:C(["badge",e.is_active?"badge-success":"badge-danger"])},d(e.is_active?"Active":"Inactive"),3)]),s("td",null,[e.last_poll?(a(),l("span",Y,d(o.formatDate(e.last_poll)),1)):(a(),l("span",Z,"Never"))]),s("td",null,[s("div",$,[s("button",{onClick:r=>o.testConnection(e.id),class:"btn btn-outline btn-sm"},"Test",8,ss),s("button",{onClick:r=>o.pollHost(e.id),class:"btn btn-outline btn-sm"},"Poll",8,ts),s("button",{onClick:r=>o.deleteHost(e.id),class:"btn btn-danger btn-sm"},"Delete",8,os)])])]))),128))])])]))]),s("div",es,[s("div",ns,[t[19]||(t[19]=s("h3",null,"Cluster Nodes",-1)),s("button",{onClick:t[1]||(t[1]=(...e)=>o.refreshAllNodes&&o.refreshAllNodes(...e)),class:"btn btn-outline"},[o.loadingNodes?(a(),l("span",as,"Loading...")):(a(),l("span",ls,"🔄 Refresh All"))])]),o.loadingNodes?(a(),l("div",rs)):o.allNodes.length===0?(a(),l("div",ds,[...t[20]||(t[20]=[s("p",null,"No cluster nodes found.",-1),s("p",{class:"text-sm"},"Poll your datacenters to discover nodes.",-1)])])):(a(),l("div",is,[(a(!0),l(H,null,A(o.datacentersWithNodes,e=>(a(),l("div",{key:e.id,class:"datacenter-section"},[s("h4",ms,d(e.name),1),e.nodes.length===0?(a(),l("div",cs,' No nodes discovered yet. Click "Poll" to discover nodes. ')):(a(),l("div",us,[(a(!0),l(H,null,A(e.nodes,r=>(a(),l("div",{key:r.id,class:"node-card"},[s("div",xs,[s("h5",null,d(r.node_name),1),s("span",{class:C(["badge",r.status==="online"?"badge-success":"badge-danger"])},d(r.status||"unknown"),3)]),s("div",ps,[s("div",fs,[t[21]||(t[21]=s("span",{class:"stat-label"},"CPU:",-1)),s("span",vs,d(r.cpu_cores)+" cores ("+d(r.cpu_usage)+"%)",1)]),s("div",bs,[t[22]||(t[22]=s("span",{class:"stat-label"},"Memory:",-1)),s("span",ys,d(o.formatBytes(r.memory_used))+" / "+d(o.formatBytes(r.memory_total)),1)]),s("div",_s,[t[23]||(t[23]=s("span",{class:"stat-label"},"Disk:",-1)),s("span",ks,d(o.formatBytes(r.disk_used))+" / "+d(o.formatBytes(r.disk_total)),1)]),s("div",gs,[t[24]||(t[24]=s("span",{class:"stat-label"},"Uptime:",-1)),s("span",hs,d(o.formatUptime(r.uptime)),1)])])]))),128))]))]))),128))]))]),o.showAddModal?(a(),l("div",{key:0,class:"modal",onClick:t[15]||(t[15]=e=>o.showAddModal=!1)},[s("div",{class:"modal-content",onClick:t[14]||(t[14]=N(()=>{},["stop"]))},[s("div",ws,[t[25]||(t[25]=s("h3",null,"Add Proxmox Datacenter",-1)),s("button",{onClick:t[2]||(t[2]=e=>o.showAddModal=!1),class:"btn-close"},"×")]),s("form",{onSubmit:t[13]||(t[13]=N((...e)=>o.addHost&&o.addHost(...e),["prevent"])),class:"modal-body"},[s("div",Hs,[t[26]||(t[26]=s("label",{class:"form-label"},"Name",-1)),m(s("input",{"onUpdate:modelValue":t[3]||(t[3]=e=>o.newHost.name=e),class:"form-control",required:""},null,512),[[u,o.newHost.name]])]),s("div",As,[t[27]||(t[27]=s("label",{class:"form-label"},"Hostname/IP",-1)),m(s("input",{"onUpdate:modelValue":t[4]||(t[4]=e=>o.newHost.hostname=e),class:"form-control",required:""},null,512),[[u,o.newHost.hostname]])]),s("div",Ps,[t[28]||(t[28]=s("label",{class:"form-label"},"Port",-1)),m(s("input",{"onUpdate:modelValue":t[5]||(t[5]=e=>o.newHost.port=e),type:"number",class:"form-control"},null,512),[[u,o.newHost.port]])]),s("div",Cs,[t[29]||(t[29]=s("label",{class:"form-label"},"Username",-1)),m(s("input",{"onUpdate:modelValue":t[6]||(t[6]=e=>o.newHost.username=e),class:"form-control",required:""},null,512),[[u,o.newHost.username]])]),s("div",Ns,[t[37]||(t[37]=s("h5",{class:"section-subtitle"},"Authentication Method",-1)),t[38]||(t[38]=s("p",{class:"text-sm text-muted"},"Choose one authentication method below:",-1)),s("div",Ms,[s("label",Ts,[m(s("input",{"onUpdate:modelValue":t[7]||(t[7]=e=>o.useApiToken=e),type:"checkbox"},null,512),[[M,o.useApiToken]]),t[30]||(t[30]=g(" Use API Token (recommended for 2FA-enabled Proxmox) ",-1))])]),o.useApiToken?(a(),l("div",Us,[...t[31]||(t[31]=[s("strong",null,"⚠️ IMPORTANT: Privilege Separation",-1),s("p",null,[g("When creating your API token in Proxmox, you MUST "),s("strong",null,"UNCHECK"),g(' the "Privilege Separation" option.')],-1),s("p",{class:"text-xs"},"Without this, the token won't have permissions to manage VMs.",-1)])])):P("",!0),o.useApiToken?(a(),l("div",Bs,[s("div",Is,[t[33]||(t[33]=s("label",{class:"form-label"},"API Token ID",-1)),m(s("input",{"onUpdate:modelValue":t[9]||(t[9]=e=>o.newHost.api_token_id=e),class:"form-control",placeholder:"root@pam!mytoken or mytoken",required:o.useApiToken},null,8,Ss),[[u,o.newHost.api_token_id]]),t[34]||(t[34]=s("p",{class:"text-xs text-muted mt-1"},'Full format: "root@pam!mytoken" or just "mytoken"',-1))]),s("div",qs,[t[35]||(t[35]=s("label",{class:"form-label"},"API Token Secret",-1)),m(s("input",{"onUpdate:modelValue":t[10]||(t[10]=e=>o.newHost.api_token_secret=e),type:"password",class:"form-control",placeholder:"xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",required:o.useApiToken},null,8,Ls),[[u,o.newHost.api_token_secret]]),t[36]||(t[36]=s("p",{class:"text-xs text-muted mt-1"},"The UUID secret generated by Proxmox",-1))])])):(a(),l("div",Ds,[s("div",Vs,[t[32]||(t[32]=s("label",{class:"form-label"},"Password",-1)),m(s("input",{"onUpdate:modelValue":t[8]||(t[8]=e=>o.newHost.password=e),type:"password",class:"form-control",required:!o.useApiToken},null,8,Fs),[[u,o.newHost.password]])])]))]),s("div",Ws,[s("label",Es,[m(s("input",{"onUpdate:modelValue":t[11]||(t[11]=e=>o.newHost.verify_ssl=e),type:"checkbox"},null,512),[[M,o.newHost.verify_ssl]]),t[39]||(t[39]=g(" Verify SSL Certificate ",-1))])]),s("div",js,[s("button",{type:"submit",class:"btn btn-primary",disabled:o.saving},d(o.saving?"Adding...":"Add Host"),9,Rs),s("button",{type:"button",onClick:t[12]||(t[12]=e=>o.showAddModal=!1),class:"btn btn-outline"}," Cancel ")])],32)])])):P("",!0)])}const Ks=L(R,[["render",zs],["__scopeId","data-v-04139eed"]]);export{Ks as default}; diff --git a/src/frontend/dist/assets/ProxmoxHosts-nDwxpsiW.css b/src/frontend/dist/assets/ProxmoxHosts-nDwxpsiW.css new file mode 100644 index 0000000..1d173a7 --- /dev/null +++ b/src/frontend/dist/assets/ProxmoxHosts-nDwxpsiW.css @@ -0,0 +1 @@ +.page-header[data-v-04139eed]{margin-bottom:1.5rem}.page-header h2[data-v-04139eed]{margin:0 0 .5rem;font-size:1.75rem;font-weight:600;color:var(--text-primary)}.page-header .text-muted[data-v-04139eed]{font-size:.95rem}.btn-sm[data-v-04139eed]{padding:.25rem .5rem;font-size:.875rem}.nodes-section[data-v-04139eed]{padding:0}.datacenter-section[data-v-04139eed]{margin-bottom:2rem;padding-bottom:2rem;border-bottom:1px solid var(--border-color)}.datacenter-section[data-v-04139eed]:last-child{border-bottom:none;margin-bottom:0;padding-bottom:0}.datacenter-title[data-v-04139eed]{font-size:1.25rem;font-weight:600;margin-bottom:1rem;color:var(--primary-color);padding-left:.5rem;border-left:3px solid var(--primary-color)}.modal[data-v-04139eed]{position:fixed;top:0;left:0;right:0;bottom:0;background-color:#00000080;display:flex;align-items:center;justify-content:center;z-index:1000}.modal-content[data-v-04139eed]{background:#fff;border-radius:.5rem;max-width:600px;width:90%;max-height:90vh;overflow-y:auto;box-shadow:var(--shadow-lg)}.modal-header[data-v-04139eed]{padding:1.5rem;border-bottom:1px solid var(--border-color);display:flex;justify-content:space-between;align-items:center}.modal-header h3[data-v-04139eed]{margin:0}.btn-close[data-v-04139eed]{background:none;border:none;font-size:2rem;cursor:pointer;color:var(--text-secondary);line-height:1}.modal-body[data-v-04139eed]{padding:1.5rem}.auth-section[data-v-04139eed]{margin:1rem 0;padding:1rem;background-color:var(--background);border-radius:.5rem;border:1px solid var(--border-color)}.section-subtitle[data-v-04139eed]{font-size:1rem;font-weight:600;margin-bottom:.5rem;color:var(--text-primary)}.warning-box[data-v-04139eed]{margin-top:1rem;padding:1rem;background-color:#fff3cd;border:1px solid #ffc107;border-radius:.375rem;color:#856404}.warning-box strong[data-v-04139eed]{display:block;margin-bottom:.5rem;font-size:.875rem}.warning-box p[data-v-04139eed]{margin:.25rem 0;font-size:.875rem}.nodes-grid[data-v-04139eed]{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:1rem}.node-card[data-v-04139eed]{border:1px solid var(--border-color);border-radius:.5rem;padding:1rem;background:var(--background)}.node-header[data-v-04139eed]{display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem;padding-bottom:.75rem;border-bottom:1px solid var(--border-color)}.node-header h5[data-v-04139eed]{margin:0;font-size:1.125rem;font-weight:600}.node-stats[data-v-04139eed]{display:flex;flex-direction:column;gap:.5rem}.stat[data-v-04139eed]{display:flex;justify-content:space-between;font-size:.875rem}.stat-label[data-v-04139eed]{color:var(--text-secondary);font-weight:500}.stat-value[data-v-04139eed]{color:var(--text-primary);font-family:monospace} diff --git a/src/frontend/dist/assets/Settings-BzMSGQUg.js b/src/frontend/dist/assets/Settings-BzMSGQUg.js new file mode 100644 index 0000000..d1754ef --- /dev/null +++ b/src/frontend/dist/assets/Settings-BzMSGQUg.js @@ -0,0 +1 @@ +import{_ as Hs,c as l,o as n,a as e,d as c,t as i,m as Ps,w as b,e as p,v as g,b as f,B as M,q as es,A as As,x as Is,r,k as Ts,l as m}from"./index-DpP9cWht.js";const Fs={name:"Settings",setup(){const u=Is(),s=r(null),y=r(null),t=r(!1),H=r(!1),S=r(!1),w=r(!1),o=r(null),V=r(""),ts=r(null),O=r(!1),os=r(100),P=r(null),R=r(!1),_=r(!1),us=r(!1),as=r(!1),A=r(""),L=r(!1),I=r(null),h=r(null),q=r(null),D=r(!1),ls=r(!1),T=r(""),N=r(!1),F=r(null),x=r(null),ns=r(null),B=r(!1),G=r(!1),C=r(null),Y=r(null),K=r(null),Q=r(!1),rs=r(!1),W=r(""),z=r(!1),j=r(null),J=r(null),X=r(null),is=r(!1),E=r({username:"",email:""}),k=r({current_password:"",new_password:"",confirm_password:""}),U=async()=>{try{const a=await m.auth.getMe();y.value=a.data,E.value.username=y.value.username,E.value.email=y.value.email}catch(a){console.error("Failed to fetch user:",a)}},cs=async()=>{try{const a=await m.dashboard.getStats();s.value=a.data}catch(a){console.error("Failed to fetch system info:",a)}},vs=async()=>{t.value=!0;try{await m.users.update(y.value.id,{email:E.value.email}),u.success("Profile updated successfully"),await U()}catch(a){console.error("Failed to update profile:",a)}finally{t.value=!1}},ms=async()=>{if(k.value.new_password!==k.value.confirm_password){u.error("New passwords do not match");return}H.value=!0;try{await m.users.changePassword({current_password:k.value.current_password,new_password:k.value.new_password}),u.success("Password changed successfully"),k.value={current_password:"",new_password:"",confirm_password:""}}catch(a){console.error("Failed to change password:",a)}finally{H.value=!1}},fs=async()=>{S.value=!0;try{const a=await m.auth.setupTOTP();o.value=a.data,w.value=!0}catch(a){console.error("Failed to setup TOTP:",a)}finally{S.value=!1}},ps=async()=>{try{await m.auth.verifyTOTP(V.value),u.success("2FA enabled successfully"),w.value=!1,V.value="",await U()}catch(a){u.error("Invalid code. Please try again."),console.error("Failed to verify TOTP:",a)}},bs=async()=>{const a=prompt("Enter your 6-digit 2FA code to disable:");if(a){S.value=!0;try{await m.auth.disableTOTP(a),u.success("2FA disabled successfully"),await U()}catch(v){u.error("Failed to disable 2FA. Check your code."),console.error("Failed to disable TOTP:",v)}finally{S.value=!1}}},gs=a=>({admin:"danger",operator:"warning",viewer:"info"})[a]||"info",ys=async()=>{O.value=!0;try{const a=await m.logs.getBackendLogs(os.value);ts.value=a.data.logs}catch(a){console.error("Failed to fetch logs:",a),u.error("Failed to load backend logs")}finally{O.value=!1}},Z=async()=>{R.value=!0;try{const a=await m.cloudImages.checkSSHStatus();P.value=a.data.configured,P.value?u.success("SSH access is configured!"):u.warning("SSH access not configured")}catch(a){console.error("Failed to check SSH status:",a),P.value=!1}finally{R.value=!1}},Ss=()=>{navigator.clipboard.writeText("sudo /tmp/enable_cloud_images.sh"),_.value=!0,u.success("Command copied to clipboard!"),setTimeout(()=>{_.value=!1},2e3)},ws=async()=>{var a,v;if(!A.value){u.error("Please enter your Proxmox password");return}L.value=!0,I.value=null,h.value=null;try{const d=await m.setup.enableCloudImages(A.value);d.data.already_configured?(h.value=d.data.message,u.success("Cloud images already enabled!")):(h.value=d.data.message,u.success("Cloud images enabled successfully!")),A.value="",setTimeout(async()=>{await Z(),as.value=!1,h.value=null},2e3)}catch(d){console.error("Failed to enable cloud images:",d),I.value=((v=(a=d.response)==null?void 0:a.data)==null?void 0:v.detail)||"Failed to enable cloud images. Please check your password and try again.",u.error("Setup failed: "+I.value)}finally{L.value=!1}},$=async()=>{D.value=!0;try{const a=await m.cloudImages.checkSSHStatus();q.value=a.data.configured}catch(a){console.error("Failed to check cluster SSH status:",a),q.value=!1}finally{D.value=!1}},ks=async()=>{var a,v;if(!T.value){u.error("Please enter your Proxmox password");return}N.value=!0,F.value=null,x.value=null;try{const d=await m.setup.enableClusterSSH(T.value);d.data.already_configured?(x.value=d.data.message,u.success("Inter-node SSH already enabled!")):(x.value=d.data.message,u.success("Inter-node SSH enabled successfully!")),T.value="",setTimeout(()=>{ls.value=!1,x.value=null,$()},2e3)}catch(d){console.error("Failed to enable inter-node SSH:",d),F.value=((v=(a=d.response)==null?void 0:a.data)==null?void 0:v.detail)||"Failed to enable inter-node SSH. Please check your password and try again.",u.error("Setup failed: "+F.value)}finally{N.value=!1}},ds=async()=>{var a,v;B.value=!0,C.value=null;try{const d=await m.systemUpdates.check();ns.value=d.data,d.data.update_available?u.info("Update available!"):u.success("You're up to date!")}catch(d){console.error("Failed to check for updates:",d),C.value=((v=(a=d.response)==null?void 0:a.data)==null?void 0:v.detail)||"Failed to check for updates",u.error("Failed to check for updates")}finally{B.value=!1}},hs=async()=>{var a,v;if(confirm("This will update Depl0y and restart the service. Continue?")){G.value=!0,C.value=null,Y.value=null;try{const d=await m.systemUpdates.apply();Y.value=d.data.message,u.success("Update started! Service will restart shortly."),setTimeout(()=>{u.info("Reloading page in 20 seconds..."),setTimeout(()=>{window.location.reload()},2e4)},5e3)}catch(d){console.error("Failed to apply update:",d),C.value=((v=(a=d.response)==null?void 0:a.data)==null?void 0:v.detail)||"Failed to apply update",u.error("Failed to apply update")}finally{G.value=!1}}},ss=async()=>{var a,v;Q.value=!0,X.value=null;try{const d=await m.ha.checkStatus();K.value=d.data}catch(d){console.error("Failed to check HA status:",d),X.value=((v=(a=d.response)==null?void 0:a.data)==null?void 0:v.detail)||"Failed to check HA status",K.value={enabled:!1,protected_vms:0}}finally{Q.value=!1}},xs=async()=>{var a,v;z.value=!0,j.value=null,J.value=null;try{const d=await m.ha.enable({proxmox_password:W.value});J.value=d.data.message||"High Availability enabled successfully!",u.success("High Availability enabled!"),setTimeout(()=>{rs.value=!1,W.value="",ss()},2e3)}catch(d){console.error("Failed to enable HA:",d),j.value=((v=(a=d.response)==null?void 0:a.data)==null?void 0:v.detail)||"Failed to enable HA",u.error("Failed to enable High Availability")}finally{z.value=!1}},Cs=()=>{is.value=!0};return Ts(()=>{U(),cs(),Z(),$(),ds(),ss()}),{systemInfo:s,user:y,profileForm:E,passwordForm:k,updatingProfile:t,changingPassword:H,totp_loading:S,showTOTPModal:w,totpSetup:o,totpCode:V,backendLogs:ts,loadingLogs:O,logLines:os,sshStatus:P,checkingSSH:R,commandCopied:_,showSetupInstructions:us,showPasswordPrompt:as,proxmoxPassword:A,enablingCloudImages:L,setupError:I,setupSuccess:h,clusterSSHStatus:q,checkingClusterSSH:D,showClusterSSHPrompt:ls,clusterPassword:T,enablingClusterSSH:N,clusterSSHError:F,clusterSSHSuccess:x,checkClusterSSHStatus:$,updateProfile:vs,changePassword:ms,setupTOTP:fs,verifyTOTP:ps,disableTOTP:bs,getRoleBadge:gs,refreshLogs:ys,checkSSHStatus:Z,copyCommand:Ss,enableCloudImages:ws,enableClusterSSH:ks,updateInfo:ns,checkingUpdates:B,applyingUpdate:G,updateError:C,updateSuccess:Y,checkForUpdates:ds,applyUpdate:hs,haStatus:K,checkingHA:Q,showHAPrompt:rs,haPassword:W,enablingHA:z,haSetupError:j,haSetupSuccess:J,haError:X,showHAManagement:is,checkHAStatus:ss,enableHA:xs,manageHAGroups:Cs}}},Es={class:"settings-page"},Us={class:"card"},Ms={class:"profile-content"},Vs={key:0,class:"profile-section"},Os={class:"profile-header"},Rs={class:"profile-avatar"},_s={class:"text-sm text-muted"},Ls={class:"profile-details"},qs={class:"form-group"},Ds={class:"form-group"},Ns=["disabled"],Bs={class:"form-group"},Gs={class:"form-group"},Ys={class:"form-group"},Ks=["disabled"],Qs={class:"totp-section"},Ws={key:0,class:"totp-enabled"},zs=["disabled"],js={key:1,class:"totp-disabled"},Js=["disabled"],Xs={class:"card"},Zs={class:"cloud-setup-content"},$s={key:0,class:"loading-message"},se={key:1,class:"success-box"},ee={key:2,class:"warning-box"},te={class:"action-buttons"},oe=["disabled"],ae={class:"command-box"},le={class:"action-buttons"},ne={class:"card"},re={class:"card-body"},ie={key:0,class:"loading-message"},de={key:1,class:"success-box"},ue={key:2,class:"warning-box"},ce={class:"action-buttons",style:{"margin-top":"1rem"}},ve=["disabled"],me={class:"card"},fe={class:"card-body"},pe={key:0,class:"loading-message"},be={key:1,class:"success-box"},ge={class:"text-sm text-muted"},ye={key:2,class:"warning-box"},Se={class:"action-buttons",style:{"margin-top":"1rem"}},we=["disabled"],ke={key:3,class:"error-message",style:{"margin-top":"1rem"}},he={class:"card"},xe={class:"card-body"},Ce={key:0,class:"info-box"},He={key:1,class:"info-box alert-danger"},Pe={key:2,class:"info-box"},Ae={key:0,class:"text-success"},Ie={key:1,class:"text-muted"},Te={key:3,class:"release-notes"},Fe={class:"action-buttons",style:{"margin-top":"1rem"}},Ee=["disabled"],Ue=["disabled"],Me={key:4,class:"error-message",style:{"margin-top":"1rem"}},Ve={key:5,class:"success-message",style:{"margin-top":"1rem"}},Oe={class:"modal-header"},Re={class:"modal-footer"},_e={class:"modal-header"},Le={class:"modal-body"},qe={class:"form-group"},De=["disabled"],Ne={key:0,class:"error-message"},Be={key:1,class:"success-message"},Ge={class:"modal-footer"},Ye=["disabled"],Ke=["disabled"],Qe={class:"modal-header"},We={class:"modal-body"},ze={key:0,class:"totp-setup"},je={class:"qr-code"},Je=["src"],Xe={class:"secret-code"},Ze={class:"form-group"},$e={class:"card"},st={class:"about-content"},et={class:"info-grid"},tt={class:"info-item"},ot={class:"info-value"},at={key:3,class:"card"},lt={class:"system-info"},nt={class:"info-row"},rt={class:"info-value"},it={class:"info-row"},dt={class:"info-value"},ut={class:"info-row"},ct={class:"info-value"},vt={key:4,class:"card"},mt={class:"card-header"},ft=["disabled"],pt={class:"logs-content"},bt={class:"logs-controls"},gt={class:"form-label"},yt={key:0,class:"logs-viewer"},St={class:"logs-text"},wt={key:1,class:"logs-empty"},kt={class:"modal-header"},ht={class:"modal-body"},xt={class:"form-group"},Ct=["disabled"],Ht={key:0,class:"error-message"},Pt={key:1,class:"success-message"},At={class:"modal-footer"},It=["disabled"],Tt=["disabled"],Ft={class:"modal-header"},Et={class:"modal-body"},Ut={class:"form-group",style:{"margin-top":"1rem"}},Mt=["disabled"],Vt={key:0,class:"error-message"},Ot={key:1,class:"success-message"},Rt={class:"modal-footer"},_t=["disabled"],Lt=["disabled"],qt={class:"modal-header"},Dt={class:"modal-footer"};function Nt(u,s,y,t,H,S){var w;return n(),l("div",Es,[e("div",Us,[s[67]||(s[67]=e("div",{class:"card-header"},[e("h3",null,"User Profile")],-1)),e("div",Ms,[t.user?(n(),l("div",Vs,[e("div",Os,[e("div",Rs,i(t.user.username.charAt(0).toUpperCase()),1),e("div",null,[e("h4",null,i(t.user.username),1),e("p",_s,i(t.user.email),1),e("span",{class:Ps(["badge","badge-"+t.getRoleBadge(t.user.role)])},i(t.user.role),3)])]),e("div",Ls,[s[64]||(s[64]=e("h5",{class:"subsection-title"},"Account Information",-1)),e("form",{onSubmit:s[2]||(s[2]=b((...o)=>t.updateProfile&&t.updateProfile(...o),["prevent"])),class:"profile-form"},[e("div",qs,[s[56]||(s[56]=e("label",{class:"form-label"},"Username",-1)),p(e("input",{"onUpdate:modelValue":s[0]||(s[0]=o=>t.profileForm.username=o),class:"form-control",disabled:""},null,512),[[g,t.profileForm.username]]),s[57]||(s[57]=e("p",{class:"text-xs text-muted"},"Username cannot be changed",-1))]),e("div",Ds,[s[58]||(s[58]=e("label",{class:"form-label"},"Email Address",-1)),p(e("input",{"onUpdate:modelValue":s[1]||(s[1]=o=>t.profileForm.email=o),type:"email",class:"form-control",required:""},null,512),[[g,t.profileForm.email]])]),e("button",{type:"submit",class:"btn btn-primary",disabled:t.updatingProfile},i(t.updatingProfile?"Updating...":"Update Profile"),9,Ns)],32),s[65]||(s[65]=e("h5",{class:"subsection-title",style:{"margin-top":"2rem"}},"Change Password",-1)),e("form",{onSubmit:s[6]||(s[6]=b((...o)=>t.changePassword&&t.changePassword(...o),["prevent"])),class:"password-form"},[e("div",Bs,[s[59]||(s[59]=e("label",{class:"form-label"},"Current Password",-1)),p(e("input",{"onUpdate:modelValue":s[3]||(s[3]=o=>t.passwordForm.current_password=o),type:"password",class:"form-control",required:""},null,512),[[g,t.passwordForm.current_password]])]),e("div",Gs,[s[60]||(s[60]=e("label",{class:"form-label"},"New Password",-1)),p(e("input",{"onUpdate:modelValue":s[4]||(s[4]=o=>t.passwordForm.new_password=o),type:"password",class:"form-control",required:""},null,512),[[g,t.passwordForm.new_password]])]),e("div",Ys,[s[61]||(s[61]=e("label",{class:"form-label"},"Confirm New Password",-1)),p(e("input",{"onUpdate:modelValue":s[5]||(s[5]=o=>t.passwordForm.confirm_password=o),type:"password",class:"form-control",required:""},null,512),[[g,t.passwordForm.confirm_password]])]),e("button",{type:"submit",class:"btn btn-primary",disabled:t.changingPassword},i(t.changingPassword?"Changing...":"Change Password"),9,Ks)],32),s[66]||(s[66]=e("h5",{class:"subsection-title",style:{"margin-top":"2rem"}},"Two-Factor Authentication (2FA)",-1)),e("div",Qs,[t.user.totp_enabled?(n(),l("div",Ws,[s[62]||(s[62]=e("div",{class:"flex gap-2 align-center"},[e("span",{class:"badge badge-success"},"✓ Enabled"),e("p",{class:"text-sm"},"2FA is currently enabled for your account")],-1)),e("button",{onClick:s[7]||(s[7]=(...o)=>t.disableTOTP&&t.disableTOTP(...o)),class:"btn btn-danger",disabled:t.totp_loading},i(t.totp_loading?"Disabling...":"Disable 2FA"),9,zs)])):(n(),l("div",js,[s[63]||(s[63]=e("div",{class:"flex gap-2 align-center"},[e("span",{class:"badge badge-warning"},"⚠ Disabled"),e("p",{class:"text-sm"},"Enable 2FA for enhanced account security")],-1)),e("button",{onClick:s[8]||(s[8]=(...o)=>t.setupTOTP&&t.setupTOTP(...o)),class:"btn btn-primary",disabled:t.totp_loading},i(t.totp_loading?"Setting up...":"Enable 2FA"),9,Js)]))])])])):c("",!0)])]),e("div",Xs,[s[77]||(s[77]=e("div",{class:"card-header"},[e("h3",null,"Cloud Image Setup")],-1)),e("div",Zs,[s[76]||(s[76]=e("div",{class:"info-box"},[e("h4",null,"🚀 Enable Automatic Cloud Image Deployments"),e("p",null,"Cloud images allow you to deploy VMs with the OS pre-installed in seconds!"),e("p",{class:"text-sm text-muted"}," This one-time setup configures SSH access to your Proxmox server, enabling: "),e("ul",{class:"feature-list"},[e("li",null,"✅ Automatic OS installation (Ubuntu, Debian, etc.)"),e("li",null,"✅ Pre-configured credentials"),e("li",null,"✅ 30-second deployments (after initial template creation)"),e("li",null,"✅ No manual installation needed")])],-1)),t.sshStatus===null?(n(),l("div",$s,[...s[68]||(s[68]=[e("div",{class:"loading-spinner"},null,-1),e("p",null,"Checking SSH configuration...",-1)])])):t.sshStatus===!0?(n(),l("div",se,[...s[69]||(s[69]=[e("div",{class:"status-icon"},"✅",-1),e("div",null,[e("h4",null,"Cloud Images Enabled!"),e("p",null,"SSH access is configured. You can now deploy VMs from cloud images."),e("p",{class:"text-sm text-muted"},"First deployment per image takes ~5-10 minutes. All subsequent deployments take ~30 seconds.")],-1)])])):(n(),l("div",ee,[s[75]||(s[75]=e("div",{class:"status-icon"},"⚠️",-1)),e("div",null,[s[71]||(s[71]=e("h4",null,"Setup Required",-1)),s[72]||(s[72]=e("p",null,[e("strong",null,"Quick Setup:"),f(" Click the button below to enable cloud images automatically!")],-1)),e("div",te,[e("button",{onClick:s[9]||(s[9]=o=>t.showPasswordPrompt=!0),class:"btn btn-success btn-lg"}," 🚀 Enable Cloud Images Now "),e("button",{onClick:s[10]||(s[10]=(...o)=>t.checkSSHStatus&&t.checkSSHStatus(...o)),class:"btn btn-outline",disabled:t.checkingSSH},i(t.checkingSSH?"Checking...":"Re-check Status"),9,oe)]),s[73]||(s[73]=e("div",{class:"or-divider"},[e("span",null,"OR")],-1)),s[74]||(s[74]=e("p",{class:"text-sm text-muted"},[e("strong",null,"Manual Setup:"),f(" Run this command on your server:")],-1)),e("div",ae,[s[70]||(s[70]=e("code",null,"sudo /tmp/enable_cloud_images.sh",-1)),e("button",{onClick:s[11]||(s[11]=(...o)=>t.copyCommand&&t.copyCommand(...o)),class:"btn btn-sm btn-outline"},i(t.commandCopied?"✓ Copied":"Copy"),1)]),e("div",le,[e("button",{onClick:s[12]||(s[12]=o=>t.showSetupInstructions=!0),class:"btn btn-sm btn-outline"}," 📖 View Full Instructions ")])])]))])]),e("div",ne,[s[82]||(s[82]=e("div",{class:"card-header"},[e("h3",null,"🔗 Proxmox Cluster Inter-Node SSH"),e("p",null,"Required for multi-node clusters to create templates on specific nodes")],-1)),e("div",re,[s[81]||(s[81]=e("div",{class:"info-box"},[e("p",null,[e("strong",null,"Why needed?"),f(" When deploying to different nodes in your cluster, the system needs to create cloud image templates on each specific node. This requires SSH access between cluster nodes.")]),e("p",{class:"text-sm text-muted"},"Single-node setups don't need this.")],-1)),t.clusterSSHStatus===null?(n(),l("div",ie,[...s[78]||(s[78]=[e("div",{class:"loading-spinner"},null,-1),e("p",null,"Checking inter-node SSH configuration...",-1)])])):t.clusterSSHStatus===!0?(n(),l("div",de,[...s[79]||(s[79]=[e("div",{class:"status-icon"},"✅",-1),e("div",null,[e("h4",null,"Inter-Node SSH Enabled!"),e("p",null,"SSH keys are configured between cluster nodes."),e("p",{class:"text-sm text-muted"},"Cloud image templates can be created on any node in your cluster.")],-1)])])):(n(),l("div",ue,[...s[80]||(s[80]=[e("div",{class:"status-icon"},"⚠️",-1),e("div",null,[e("h4",null,"Setup Required"),e("p",null,"Inter-node SSH is not configured yet.")],-1)])])),e("div",ce,[t.clusterSSHStatus!==!0?(n(),l("button",{key:0,onClick:s[13]||(s[13]=o=>t.showClusterSSHPrompt=!0),class:"btn btn-primary"}," 🔐 Enable Inter-Node SSH ")):(n(),l("button",{key:1,onClick:s[14]||(s[14]=(...o)=>t.checkClusterSSHStatus&&t.checkClusterSSHStatus(...o)),class:"btn btn-outline",disabled:t.checkingClusterSSH},i(t.checkingClusterSSH?"Checking...":"🔄 Re-check Status"),9,ve))])])]),e("div",me,[s[89]||(s[89]=e("div",{class:"card-header"},[e("h3",null,"🔒 High Availability (HA) & Failover"),e("p",null,"Automatically restart VMs on other nodes if a node fails")],-1)),e("div",fe,[s[88]||(s[88]=e("div",{class:"info-box"},[e("p",null,[e("strong",null,"What is HA?"),f(" High Availability ensures your critical VMs automatically restart on another node if the current node fails or goes offline.")]),e("p",{class:"text-sm text-muted"},[e("strong",null,"Requirements:"),f(" Multi-node Proxmox cluster with shared storage ")]),e("ul",{class:"feature-list text-sm"},[e("li",null,"✅ Automatic VM migration on node failure"),e("li",null,"✅ Configurable restart priorities"),e("li",null,"✅ Minimal downtime for critical services"),e("li",null,"✅ Health monitoring and alerting")])],-1)),t.haStatus===null?(n(),l("div",pe,[...s[83]||(s[83]=[e("div",{class:"loading-spinner"},null,-1),e("p",null,"Checking HA status...",-1)])])):t.haStatus&&t.haStatus.enabled?(n(),l("div",be,[s[86]||(s[86]=e("div",{class:"status-icon"},"✅",-1)),e("div",null,[s[84]||(s[84]=e("h4",null,"HA Enabled",-1)),s[85]||(s[85]=e("p",null,"High Availability is configured and active on your cluster.",-1)),e("p",ge,"Protected VMs: "+i(t.haStatus.protected_vms||0),1)])])):(n(),l("div",ye,[...s[87]||(s[87]=[e("div",{class:"status-icon"},"⚠️",-1),e("div",null,[e("h4",null,"HA Not Configured"),e("p",null,"Enable High Availability to protect your VMs from node failures.")],-1)])])),e("div",Se,[e("button",{onClick:s[15]||(s[15]=(...o)=>t.checkHAStatus&&t.checkHAStatus(...o)),class:"btn btn-outline",disabled:t.checkingHA},i(t.checkingHA?"Checking...":"🔍 Check HA Status"),9,we),t.haStatus&&!t.haStatus.enabled?(n(),l("button",{key:0,onClick:s[16]||(s[16]=o=>t.showHAPrompt=!0),class:"btn btn-success"}," 🔒 Enable High Availability ")):c("",!0)]),t.haError?(n(),l("div",ke,i(t.haError),1)):c("",!0)])]),e("div",he,[s[96]||(s[96]=e("div",{class:"card-header"},[e("h3",null,"🔄 System Updates"),e("p",null,"Keep Depl0y up to date from deploy.agit8or.net")],-1)),e("div",xe,[t.checkingUpdates&&!t.updateInfo?(n(),l("div",Ce,[...s[90]||(s[90]=[e("p",{class:"text-muted"},"⏳ Checking for updates...",-1)])])):t.updateError&&!t.updateInfo?(n(),l("div",He,[e("p",null,[s[91]||(s[91]=e("strong",null,"Error:",-1)),f(" "+i(t.updateError),1)]),s[92]||(s[92]=e("p",{class:"text-muted"},'Click "Check for Updates" to try again',-1))])):t.updateInfo?(n(),l("div",Pe,[e("p",null,[s[93]||(s[93]=e("strong",null,"Current Version:",-1)),f(" "+i(t.updateInfo.current_version),1)]),e("p",null,[s[94]||(s[94]=e("strong",null,"Latest Version:",-1)),f(" "+i(t.updateInfo.latest_version),1)]),t.updateInfo.update_available?(n(),l("p",Ae," ✨ Update available! ")):(n(),l("p",Ie," ✅ You're running the latest version "))])):c("",!0),t.updateInfo&&t.updateInfo.release_notes?(n(),l("div",Te,[s[95]||(s[95]=e("h4",null,"Release Notes:",-1)),e("pre",null,i(t.updateInfo.release_notes),1)])):c("",!0),e("div",Fe,[e("button",{onClick:s[17]||(s[17]=(...o)=>t.checkForUpdates&&t.checkForUpdates(...o)),class:"btn btn-outline",disabled:t.checkingUpdates},i(t.checkingUpdates?"Checking...":"🔍 Check for Updates"),9,Ee),t.updateInfo&&t.updateInfo.update_available?(n(),l("button",{key:0,onClick:s[18]||(s[18]=(...o)=>t.applyUpdate&&t.applyUpdate(...o)),class:"btn btn-success",disabled:t.applyingUpdate},i(t.applyingUpdate?"Updating...":"⬇️ Install Update"),9,Ue)):c("",!0)]),t.updateError?(n(),l("div",Me,i(t.updateError),1)):c("",!0),t.updateSuccess?(n(),l("div",Ve,i(t.updateSuccess),1)):c("",!0)])]),t.showSetupInstructions?(n(),l("div",{key:0,class:"modal",onClick:s[22]||(s[22]=o=>t.showSetupInstructions=!1)},[e("div",{class:"modal-content modal-large",onClick:s[21]||(s[21]=b(()=>{},["stop"]))},[e("div",Oe,[s[97]||(s[97]=e("h3",null,"Cloud Image Setup Instructions",-1)),e("button",{onClick:s[19]||(s[19]=o=>t.showSetupInstructions=!1),class:"btn-close"},"×")]),s[98]||(s[98]=M('',1)),e("div",Re,[e("button",{onClick:s[20]||(s[20]=o=>t.showSetupInstructions=!1),class:"btn btn-primary"}," Got It! ")])])])):c("",!0),t.showPasswordPrompt?(n(),l("div",{key:1,class:"modal",onClick:s[29]||(s[29]=o=>t.showPasswordPrompt=!1)},[e("div",{class:"modal-content",onClick:s[28]||(s[28]=b(()=>{},["stop"]))},[e("div",_e,[s[99]||(s[99]=e("h3",null,"🚀 Enable Cloud Images",-1)),e("button",{onClick:s[23]||(s[23]=o=>t.showPasswordPrompt=!1),class:"btn-close"},"×")]),e("div",Le,[s[102]||(s[102]=e("p",null,"To enable cloud images, we need to configure SSH access to your Proxmox server.",-1)),s[103]||(s[103]=e("p",{class:"text-sm text-muted"},"This is a one-time setup that takes about 30 seconds.",-1)),e("div",qe,[s[100]||(s[100]=e("label",{for:"proxmox-password"},"Proxmox Root Password",-1)),p(e("input",{type:"password",id:"proxmox-password","onUpdate:modelValue":s[24]||(s[24]=o=>t.proxmoxPassword=o),class:"form-control",placeholder:"Enter Proxmox root password",disabled:t.enablingCloudImages,onKeyup:s[25]||(s[25]=es((...o)=>t.enableCloudImages&&t.enableCloudImages(...o),["enter"]))},null,40,De),[[g,t.proxmoxPassword]]),s[101]||(s[101]=e("p",{class:"text-sm text-muted mt-2"}," 🔒 Your password is only used once to copy the SSH key and is not stored anywhere. ",-1))]),t.setupError?(n(),l("div",Ne,i(t.setupError),1)):c("",!0),t.setupSuccess?(n(),l("div",Be," ✅ "+i(t.setupSuccess),1)):c("",!0)]),e("div",Ge,[e("button",{onClick:s[26]||(s[26]=o=>t.showPasswordPrompt=!1),class:"btn btn-outline",disabled:t.enablingCloudImages}," Cancel ",8,Ye),e("button",{onClick:s[27]||(s[27]=(...o)=>t.enableCloudImages&&t.enableCloudImages(...o)),class:"btn btn-success",disabled:!t.proxmoxPassword||t.enablingCloudImages},i(t.enablingCloudImages?"Setting up...":"Enable Cloud Images"),9,Ke)])])])):c("",!0),t.showTOTPModal?(n(),l("div",{key:2,class:"modal",onClick:s[34]||(s[34]=o=>t.showTOTPModal=!1)},[e("div",{class:"modal-content",onClick:s[33]||(s[33]=b(()=>{},["stop"]))},[e("div",Qe,[s[104]||(s[104]=e("h3",null,"Setup Two-Factor Authentication",-1)),e("button",{onClick:s[30]||(s[30]=o=>t.showTOTPModal=!1),class:"btn-close"},"×")]),e("div",We,[t.totpSetup?(n(),l("div",ze,[s[107]||(s[107]=e("p",null,"Scan this QR code with your authenticator app (Google Authenticator, Authy, etc.)",-1)),e("div",je,[e("img",{src:t.totpSetup.qr_code,alt:"QR Code"},null,8,Je)]),s[108]||(s[108]=e("p",{class:"text-sm text-muted"},"Or enter this code manually:",-1)),e("code",Xe,i(t.totpSetup.secret),1),e("form",{onSubmit:s[32]||(s[32]=b((...o)=>t.verifyTOTP&&t.verifyTOTP(...o),["prevent"])),class:"totp-verify-form"},[e("div",Ze,[s[105]||(s[105]=e("label",{class:"form-label"},"Enter 6-digit code from your app:",-1)),p(e("input",{"onUpdate:modelValue":s[31]||(s[31]=o=>t.totpCode=o),type:"text",class:"form-control",maxlength:"6",pattern:"[0-9]{6}",required:""},null,512),[[g,t.totpCode]])]),s[106]||(s[106]=e("button",{type:"submit",class:"btn btn-primary"}," Verify & Enable ",-1))],32)])):c("",!0)])])])):c("",!0),e("div",$e,[s[113]||(s[113]=e("div",{class:"card-header"},[e("h3",null,"About Depl0y")],-1)),e("div",st,[s[111]||(s[111]=e("div",{class:"logo-section"},[e("h1",{class:"about-logo"},[f("Depl"),e("span",{class:"logo-zero"},"0"),f("y")]),e("p",{class:"tagline"},"VM Deployment Panel for Proxmox VE")],-1)),e("div",et,[e("div",tt,[s[109]||(s[109]=e("span",{class:"info-label"},"Version",-1)),e("span",ot,i(((w=t.updateInfo)==null?void 0:w.current_version)||"Loading..."),1)]),s[110]||(s[110]=M('
    StatusRunning
    BackendFastAPI (Python)
    FrontendVue.js 3
    DatabaseSQLite
    LicenseOpen Source
    ',5))]),s[112]||(s[112]=M('
    🚀

    Built by

    Agit8or

    www.agit8or.net

    Features

    Automated VM Deployment
    Cloud-init Support
    ISO Management
    Multi-user Authentication
    2FA Support (TOTP)
    Update Management
    Resource Monitoring
    Proxmox API Integration
    ',2))])]),t.systemInfo?(n(),l("div",at,[s[117]||(s[117]=e("div",{class:"card-header"},[e("h3",null,"System Information")],-1)),e("div",lt,[e("div",nt,[s[114]||(s[114]=e("span",{class:"info-label"},"Total VMs",-1)),e("span",rt,i(t.systemInfo.total_vms),1)]),e("div",it,[s[115]||(s[115]=e("span",{class:"info-label"},"Active Proxmox Hosts",-1)),e("span",dt,i(t.systemInfo.datacenters),1)]),e("div",ut,[s[116]||(s[116]=e("span",{class:"info-label"},"ISO Images",-1)),e("span",ct,i(t.systemInfo.total_isos),1)])])])):c("",!0),t.user&&t.user.role==="admin"?(n(),l("div",vt,[e("div",mt,[s[118]||(s[118]=e("h3",null,"Backend Logs",-1)),e("button",{onClick:s[35]||(s[35]=(...o)=>t.refreshLogs&&t.refreshLogs(...o)),class:"btn btn-primary",disabled:t.loadingLogs},i(t.loadingLogs?"Loading...":"Refresh"),9,ft)]),e("div",pt,[e("div",bt,[e("label",gt,[s[120]||(s[120]=f(" Show last ",-1)),p(e("select",{"onUpdate:modelValue":s[36]||(s[36]=o=>t.logLines=o),onChange:s[37]||(s[37]=(...o)=>t.refreshLogs&&t.refreshLogs(...o)),class:"log-lines-select"},[...s[119]||(s[119]=[e("option",{value:50},"50",-1),e("option",{value:100},"100",-1),e("option",{value:200},"200",-1),e("option",{value:500},"500",-1)])],544),[[As,t.logLines]]),s[121]||(s[121]=f(" lines ",-1))])]),t.backendLogs?(n(),l("div",yt,[e("pre",St,i(t.backendLogs),1)])):(n(),l("div",wt,[...s[122]||(s[122]=[e("p",null,'Click "Refresh" to load logs',-1)])]))])])):c("",!0),t.showClusterSSHPrompt?(n(),l("div",{key:5,class:"modal",onClick:s[44]||(s[44]=o=>t.showClusterSSHPrompt=!1)},[e("div",{class:"modal-content",onClick:s[43]||(s[43]=b(()=>{},["stop"]))},[e("div",kt,[s[123]||(s[123]=e("h3",null,"🔐 Enable Inter-Node SSH",-1)),e("button",{onClick:s[38]||(s[38]=o=>t.showClusterSSHPrompt=!1),class:"btn-close"},"×")]),e("div",ht,[s[126]||(s[126]=e("p",null,"To enable inter-node SSH, we need to configure SSH keys between your Proxmox cluster nodes.",-1)),s[127]||(s[127]=e("p",{class:"text-sm text-muted"},"This is a one-time setup that takes about 30 seconds.",-1)),e("div",xt,[s[124]||(s[124]=e("label",{for:"cluster-password"},"Proxmox Root Password",-1)),p(e("input",{type:"password",id:"cluster-password","onUpdate:modelValue":s[39]||(s[39]=o=>t.clusterPassword=o),class:"form-control",placeholder:"Enter Proxmox root password",disabled:t.enablingClusterSSH,onKeyup:s[40]||(s[40]=es((...o)=>t.enableClusterSSH&&t.enableClusterSSH(...o),["enter"]))},null,40,Ct),[[g,t.clusterPassword]]),s[125]||(s[125]=e("p",{class:"text-sm text-muted mt-2"}," 🔒 Your password is only used once to set up SSH keys and is not stored anywhere. ",-1))]),t.clusterSSHError?(n(),l("div",Ht,i(t.clusterSSHError),1)):c("",!0),t.clusterSSHSuccess?(n(),l("div",Pt," ✅ "+i(t.clusterSSHSuccess),1)):c("",!0)]),e("div",At,[e("button",{onClick:s[41]||(s[41]=o=>t.showClusterSSHPrompt=!1),class:"btn btn-outline",disabled:t.enablingClusterSSH}," Cancel ",8,It),e("button",{onClick:s[42]||(s[42]=(...o)=>t.enableClusterSSH&&t.enableClusterSSH(...o)),class:"btn btn-primary",disabled:!t.clusterPassword||t.enablingClusterSSH},i(t.enablingClusterSSH?"Setting up...":"Enable Inter-Node SSH"),9,Tt)])])])):c("",!0),t.showHAPrompt?(n(),l("div",{key:6,class:"modal",onClick:s[51]||(s[51]=o=>t.showHAPrompt=!1)},[e("div",{class:"modal-content",onClick:s[50]||(s[50]=b(()=>{},["stop"]))},[e("div",Ft,[s[128]||(s[128]=e("h3",null,"🔒 Enable High Availability",-1)),e("button",{onClick:s[45]||(s[45]=o=>t.showHAPrompt=!1),class:"btn-close"},"×")]),e("div",Et,[s[131]||(s[131]=e("p",null,"High Availability will be enabled on your Proxmox cluster, allowing automatic failover of VMs.",-1)),s[132]||(s[132]=e("p",{class:"text-sm text-muted"},"This requires:",-1)),s[133]||(s[133]=e("ul",{class:"text-sm"},[e("li",null,"Multi-node Proxmox cluster"),e("li",null,"Shared storage accessible by all nodes"),e("li",null,"Proxmox root access")],-1)),e("div",Ut,[s[129]||(s[129]=e("label",{for:"ha-password"},"Proxmox Root Password",-1)),p(e("input",{type:"password",id:"ha-password","onUpdate:modelValue":s[46]||(s[46]=o=>t.haPassword=o),class:"form-control",placeholder:"Enter Proxmox root password",disabled:t.enablingHA,onKeyup:s[47]||(s[47]=es((...o)=>t.enableHA&&t.enableHA(...o),["enter"]))},null,40,Mt),[[g,t.haPassword]]),s[130]||(s[130]=e("p",{class:"text-sm text-muted mt-2"}," 🔒 Your password is only used once to configure HA and is not stored. ",-1))]),t.haSetupError?(n(),l("div",Vt,i(t.haSetupError),1)):c("",!0),t.haSetupSuccess?(n(),l("div",Ot," ✅ "+i(t.haSetupSuccess),1)):c("",!0)]),e("div",Rt,[e("button",{onClick:s[48]||(s[48]=o=>t.showHAPrompt=!1),class:"btn btn-outline",disabled:t.enablingHA}," Cancel ",8,_t),e("button",{onClick:s[49]||(s[49]=(...o)=>t.enableHA&&t.enableHA(...o)),class:"btn btn-success",disabled:!t.haPassword||t.enablingHA},i(t.enablingHA?"Enabling HA...":"🔒 Enable High Availability"),9,Lt)])])])):c("",!0),t.showHAManagement?(n(),l("div",{key:7,class:"modal",onClick:s[55]||(s[55]=o=>t.showHAManagement=!1)},[e("div",{class:"modal-content modal-large",onClick:s[54]||(s[54]=b(()=>{},["stop"]))},[e("div",qt,[s[134]||(s[134]=e("h3",null,"⚙️ Manage HA Groups",-1)),e("button",{onClick:s[52]||(s[52]=o=>t.showHAManagement=!1),class:"btn-close"},"×")]),s[135]||(s[135]=M('',1)),e("div",Dt,[e("button",{onClick:s[53]||(s[53]=o=>t.showHAManagement=!1),class:"btn btn-primary"}," Close ")])])])):c("",!0)])}const Gt=Hs(Fs,[["render",Nt],["__scopeId","data-v-5f881903"]]);export{Gt as default}; diff --git a/src/frontend/dist/assets/Settings-CLCNiZOx.css b/src/frontend/dist/assets/Settings-CLCNiZOx.css new file mode 100644 index 0000000..45e7e91 --- /dev/null +++ b/src/frontend/dist/assets/Settings-CLCNiZOx.css @@ -0,0 +1 @@ +.profile-content[data-v-5f881903]{padding:2rem}.profile-section[data-v-5f881903]{max-width:800px}.profile-header[data-v-5f881903]{display:flex;align-items:center;gap:1.5rem;padding-bottom:2rem;border-bottom:2px solid var(--border-color);margin-bottom:2rem}.profile-avatar[data-v-5f881903]{width:80px;height:80px;border-radius:50%;background:linear-gradient(135deg,var(--primary-color),var(--secondary-color));display:flex;align-items:center;justify-content:center;color:#fff;font-size:2rem;font-weight:700}.profile-header h4[data-v-5f881903]{margin:0 0 .5rem;font-size:1.5rem}.subsection-title[data-v-5f881903]{font-size:1.125rem;font-weight:600;margin-bottom:1rem;color:var(--text-primary)}.profile-details[data-v-5f881903]{margin-top:2rem}.profile-form[data-v-5f881903],.password-form[data-v-5f881903]{max-width:500px}.totp-section[data-v-5f881903]{padding:1.5rem;background-color:var(--background);border-radius:.5rem;border:1px solid var(--border-color);max-width:500px}.totp-enabled[data-v-5f881903],.totp-disabled[data-v-5f881903]{display:flex;flex-direction:column;gap:1rem}.totp-setup[data-v-5f881903]{display:flex;flex-direction:column;align-items:center;gap:1rem}.qr-code[data-v-5f881903]{padding:1rem;background:#fff;border-radius:.5rem;border:1px solid var(--border-color)}.qr-code img[data-v-5f881903]{display:block;max-width:250px}.secret-code[data-v-5f881903]{padding:.75rem 1rem;background-color:var(--background);border:1px solid var(--border-color);border-radius:.375rem;font-family:monospace;font-size:1rem;display:block;text-align:center;letter-spacing:.1em}.totp-verify-form[data-v-5f881903]{width:100%;max-width:400px;margin-top:1rem}.modal[data-v-5f881903]{position:fixed;top:0;left:0;right:0;bottom:0;background-color:#00000080;display:flex;align-items:center;justify-content:center;z-index:1000}.modal-content[data-v-5f881903]{background:#fff;border-radius:.5rem;max-width:600px;width:90%;max-height:90vh;overflow-y:auto;box-shadow:var(--shadow-lg)}.modal-header[data-v-5f881903]{padding:1.5rem;border-bottom:1px solid var(--border-color);display:flex;justify-content:space-between;align-items:center}.modal-header h3[data-v-5f881903]{margin:0}.btn-close[data-v-5f881903]{background:none;border:none;font-size:2rem;cursor:pointer;color:var(--text-secondary);line-height:1}.modal-body[data-v-5f881903]{padding:1.5rem}.about-content[data-v-5f881903]{padding:2rem}.logo-section[data-v-5f881903]{text-align:center;padding:2rem 0;border-bottom:2px solid var(--border-color);margin-bottom:2rem}.about-logo[data-v-5f881903]{font-size:4rem;font-weight:700;margin:0;letter-spacing:-2px;color:var(--text-primary)}.logo-zero[data-v-5f881903]{color:#3b82f6}.tagline[data-v-5f881903]{font-size:1.25rem;color:var(--text-secondary);margin-top:.5rem}.info-grid[data-v-5f881903]{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:1.5rem;margin-bottom:2rem}.info-item[data-v-5f881903]{display:flex;flex-direction:column;gap:.5rem;padding:1rem;background-color:var(--background);border-radius:.5rem;border:1px solid var(--border-color)}.info-label[data-v-5f881903]{font-size:.75rem;text-transform:uppercase;letter-spacing:.05em;color:var(--text-secondary);font-weight:600}.info-value[data-v-5f881903]{font-size:1.125rem;font-weight:600;color:var(--text-primary)}.builder-section[data-v-5f881903]{margin:2rem 0;padding:2rem 0;border-top:2px solid var(--border-color);border-bottom:2px solid var(--border-color)}.builder-card[data-v-5f881903]{display:flex;align-items:center;gap:1.5rem;padding:1.5rem;background:linear-gradient(135deg,#1e3a8a,#1e40af,#1d4ed8);border-radius:.75rem;box-shadow:var(--shadow-lg)}.builder-icon[data-v-5f881903]{font-size:3rem;filter:drop-shadow(0 4px 6px rgba(0,0,0,.3))}.builder-info[data-v-5f881903]{color:#fff}.builder-info h4[data-v-5f881903]{margin:0 0 .5rem;font-size:.875rem;text-transform:uppercase;letter-spacing:.1em;opacity:.9}.builder-link[data-v-5f881903]{color:#fff;text-decoration:none;font-size:1.75rem;display:inline-block;transition:transform .2s}.builder-link[data-v-5f881903]:hover{transform:translate(5px);text-decoration:underline}.builder-url[data-v-5f881903]{margin:.25rem 0 0;font-size:1rem;opacity:.8;font-family:monospace}.features-section[data-v-5f881903]{margin-top:2rem}.section-title[data-v-5f881903]{font-size:1.25rem;font-weight:600;margin-bottom:1rem;color:var(--text-primary)}.features-grid[data-v-5f881903]{display:grid;grid-template-columns:repeat(auto-fit,minmax(250px,1fr));gap:1rem}.feature-item[data-v-5f881903]{display:flex;align-items:center;gap:.75rem;padding:.75rem;background-color:var(--background);border-radius:.5rem;border:1px solid var(--border-color);transition:all .2s}.feature-item[data-v-5f881903]:hover{border-color:var(--primary-color);box-shadow:0 0 0 2px #2563eb1a}.feature-icon[data-v-5f881903]{display:flex;align-items:center;justify-content:center;width:1.5rem;height:1.5rem;background-color:var(--secondary-color);color:#fff;border-radius:50%;font-size:.875rem;font-weight:700;flex-shrink:0}.system-info[data-v-5f881903]{padding:1.5rem}.info-row[data-v-5f881903]{display:flex;justify-content:space-between;align-items:center;padding:1rem 0;border-bottom:1px solid var(--border-color)}.info-row[data-v-5f881903]:last-child{border-bottom:none}.logs-content[data-v-5f881903]{padding:1.5rem}.logs-controls[data-v-5f881903]{margin-bottom:1rem;display:flex;align-items:center;gap:.5rem}.log-lines-select[data-v-5f881903]{margin:0 .5rem;padding:.25rem .5rem;border:1px solid var(--border-color);border-radius:.375rem;background-color:#fff;cursor:pointer}.logs-viewer[data-v-5f881903]{background-color:#1e1e1e;border-radius:.5rem;overflow:hidden;border:1px solid var(--border-color);max-height:600px;overflow-y:auto}.logs-text[data-v-5f881903]{margin:0;padding:1rem;color:#d4d4d4;font-family:Consolas,Monaco,Courier New,monospace;font-size:.875rem;line-height:1.5;white-space:pre-wrap;word-wrap:break-word;overflow-wrap:break-word}.logs-empty[data-v-5f881903]{padding:3rem;text-align:center;color:var(--text-secondary);border:2px dashed var(--border-color);border-radius:.5rem}.card-header[data-v-5f881903]{display:flex;justify-content:space-between;align-items:center}.cloud-setup-content[data-v-5f881903]{padding:2rem}.info-box[data-v-5f881903]{background:linear-gradient(135deg,#2563eb1a,#9333ea1a);border:1px solid var(--primary-color);border-radius:.5rem;padding:1.5rem;margin-bottom:1.5rem}.info-box h4[data-v-5f881903]{margin:0 0 1rem;color:var(--text-primary)}.feature-list[data-v-5f881903]{list-style:none;padding:0;margin:0}.feature-list li[data-v-5f881903]{padding:.5rem 0;color:var(--text-secondary)}.success-box[data-v-5f881903]{display:flex;gap:1rem;align-items:flex-start;padding:1.5rem;background:#d1fae5;border:2px solid #10b981;border-radius:.5rem;color:#065f46}.warning-box[data-v-5f881903]{display:flex;gap:1rem;align-items:flex-start;padding:1.5rem;background:#fef3c7;border:2px solid #f59e0b;border-radius:.5rem;color:#92400e}.status-icon[data-v-5f881903]{font-size:2rem;line-height:1}.success-box h4[data-v-5f881903],.warning-box h4[data-v-5f881903],.success-box p[data-v-5f881903],.warning-box p[data-v-5f881903]{margin:0 0 .5rem}.command-box[data-v-5f881903]{display:flex;align-items:center;gap:.5rem;background:#1e293b;padding:1rem;border-radius:.375rem;margin:1rem 0}.command-box code[data-v-5f881903]{flex:1;color:#10b981;font-family:monospace;font-size:.95rem}.action-buttons[data-v-5f881903]{display:flex;gap:1rem;margin-top:1rem;flex-wrap:wrap}.setup-instructions-modal[data-v-5f881903]{max-width:900px}.setup-instructions-modal .modal-body[data-v-5f881903]{max-height:70vh;overflow-y:auto}.setup-instructions-modal h4[data-v-5f881903]{margin-top:1.5rem;color:var(--primary-color)}.setup-instructions-modal h4[data-v-5f881903]:first-child{margin-top:0}.setup-instructions-modal ul[data-v-5f881903],.setup-instructions-modal ol[data-v-5f881903]{margin:.5rem 0 1rem 1.5rem}.setup-instructions-modal code[data-v-5f881903]{background:#1e293b;color:#10b981;padding:.125rem .375rem;border-radius:.25rem;font-size:.9rem}.setup-instructions-modal pre[data-v-5f881903]{background:#1e293b;color:#e2e8f0;padding:1rem;border-radius:.375rem;overflow-x:auto;margin:.5rem 0}.or-divider[data-v-5f881903]{text-align:center;margin:1.5rem 0;position:relative}.or-divider[data-v-5f881903]:before{content:"";position:absolute;left:0;right:0;top:50%;border-top:1px solid var(--border-color);z-index:0}.or-divider span[data-v-5f881903]{background:#fff;padding:0 1rem;position:relative;z-index:1;color:var(--text-secondary);font-size:.875rem;font-weight:600}.btn-lg[data-v-5f881903]{padding:.875rem 1.75rem;font-size:1.125rem;font-weight:600}.error-message[data-v-5f881903]{background:#fee2e2;border:1px solid #ef4444;color:#991b1b;padding:1rem;border-radius:.375rem;margin-top:1rem}.success-message[data-v-5f881903]{background:#d1fae5;border:1px solid #10b981;color:#065f46;padding:1rem;border-radius:.375rem;margin-top:1rem}.mt-2[data-v-5f881903]{margin-top:.5rem}.text-sm[data-v-5f881903]{font-size:.875rem}.text-muted[data-v-5f881903]{color:var(--text-secondary)}.modal-footer[data-v-5f881903]{padding:1rem 1.5rem;border-top:1px solid var(--border-color);display:flex;justify-content:flex-end;gap:.75rem}@media (max-width: 768px){.about-logo[data-v-5f881903]{font-size:3rem}.info-grid[data-v-5f881903],.features-grid[data-v-5f881903]{grid-template-columns:1fr}.card-header[data-v-5f881903]{flex-direction:column;gap:1rem;align-items:flex-start}.action-buttons[data-v-5f881903]{flex-direction:column}.action-buttons button[data-v-5f881903]{width:100%}} diff --git a/src/frontend/dist/assets/Users-0cuk11p1.js b/src/frontend/dist/assets/Users-0cuk11p1.js new file mode 100644 index 0000000..d6b9ea9 --- /dev/null +++ b/src/frontend/dist/assets/Users-0cuk11p1.js @@ -0,0 +1 @@ +import{_ as D,c as a,o as r,a as e,d as F,F as R,p as q,t as n,m as C,w as g,e as i,v as y,A as k,b as N,E as B,x as S,r as d,k as L,l as w}from"./index-DpP9cWht.js";const T={name:"Users",setup(){const u=S(),o=d([]),v=d(!1),l=d(!1),f=d(!1),U=d(!1),s=d(!1),c=d({username:"",email:"",password:"",role:"viewer"}),m=d({id:null,username:"",email:"",role:"",is_active:!0}),b=async()=>{v.value=!0;try{const t=await w.users.list();o.value=t.data}catch(t){console.error("Failed to fetch users:",t),u.error("Failed to load users")}finally{v.value=!1}},M=async()=>{l.value=!0;try{await w.users.create(c.value),u.success("User created successfully"),U.value=!1,c.value={username:"",email:"",password:"",role:"viewer"},await b()}catch(t){console.error("Failed to create user:",t)}finally{l.value=!1}},_=t=>{m.value={id:t.id,username:t.username,email:t.email,role:t.role,is_active:t.is_active},s.value=!0},x=async()=>{f.value=!0;try{await w.users.update(m.value.id,{email:m.value.email,role:m.value.role,is_active:m.value.is_active}),u.success("User updated successfully"),s.value=!1,await b()}catch(t){console.error("Failed to update user:",t)}finally{f.value=!1}},E=async t=>{if(confirm("Are you sure you want to delete this user? This action cannot be undone."))try{await w.users.delete(t),u.success("User deleted successfully"),await b()}catch(p){console.error("Failed to delete user:",p)}},V=t=>({admin:"Admin",operator:"Operator",viewer:"Viewer"})[t]||t,A=t=>({admin:"badge-danger",operator:"badge-warning",viewer:"badge-info"})[t]||"badge-secondary",h=t=>t?new Date(t).toLocaleString():"Never";return L(()=>{b()}),{users:o,loading:v,creating:l,updating:f,showCreateModal:U,showEditModal:s,createForm:c,editForm:m,createUser:M,editUser:_,updateUser:x,deleteUser:E,formatRole:V,getRoleBadgeClass:A,formatDate:h}}},O={class:"users-page"},I={class:"card"},z={class:"card-header"},P={key:0,class:"loading-spinner"},j={key:1,class:"text-center text-muted"},G={key:2,class:"table-container"},H={class:"table"},J={class:"text-sm"},K={class:"flex gap-1"},Q=["onClick"],W=["onClick"],X={class:"modal-content"},Y={class:"modal-header"},Z={class:"form-group"},$={class:"form-group"},ee={class:"form-group"},oe={class:"form-group"},le={class:"modal-footer"},se=["disabled"],te={class:"modal-content"},ae={class:"modal-header"},re={class:"form-group"},ne=["value"],de={class:"form-group"},ie={class:"form-group"},me={class:"form-group"},ue={class:"form-label"},ce={class:"modal-footer"},ve=["disabled"];function fe(u,o,v,l,f,U){return r(),a("div",O,[e("div",I,[e("div",z,[o[16]||(o[16]=e("h3",null,"User Management",-1)),e("button",{onClick:o[0]||(o[0]=s=>l.showCreateModal=!0),class:"btn btn-primary"},"+ Add User")]),l.loading?(r(),a("div",P)):l.users.length===0?(r(),a("div",j,[...o[17]||(o[17]=[e("p",null,"No users found.",-1)])])):(r(),a("div",G,[e("table",H,[o[18]||(o[18]=e("thead",null,[e("tr",null,[e("th",null,"Username"),e("th",null,"Email"),e("th",null,"Role"),e("th",null,"2FA"),e("th",null,"Status"),e("th",null,"Last Login"),e("th",null,"Actions")])],-1)),e("tbody",null,[(r(!0),a(R,null,q(l.users,s=>(r(),a("tr",{key:s.id},[e("td",null,[e("strong",null,n(s.username),1)]),e("td",null,n(s.email),1),e("td",null,[e("span",{class:C(["badge",l.getRoleBadgeClass(s.role)])},n(l.formatRole(s.role)),3)]),e("td",null,[e("span",{class:C(["badge",s.totp_enabled?"badge-success":"badge-secondary"])},n(s.totp_enabled?"Enabled":"Disabled"),3)]),e("td",null,[e("span",{class:C(["badge",s.is_active?"badge-success":"badge-danger"])},n(s.is_active?"Active":"Inactive"),3)]),e("td",J,n(l.formatDate(s.last_login)),1),e("td",null,[e("div",K,[e("button",{onClick:c=>l.editUser(s),class:"btn btn-outline btn-sm"}," Edit ",8,Q),e("button",{onClick:c=>l.deleteUser(s.id),class:"btn btn-danger btn-sm"}," Delete ",8,W)])])]))),128))])])]))]),l.showCreateModal?(r(),a("div",{key:0,class:"modal-overlay",onClick:o[8]||(o[8]=g(s=>l.showCreateModal=!1,["self"]))},[e("div",X,[e("div",Y,[o[19]||(o[19]=e("h3",null,"Create New User",-1)),e("button",{onClick:o[1]||(o[1]=s=>l.showCreateModal=!1),class:"modal-close"},"×")]),e("form",{onSubmit:o[7]||(o[7]=g((...s)=>l.createUser&&l.createUser(...s),["prevent"])),class:"modal-body"},[e("div",Z,[o[20]||(o[20]=e("label",{for:"username",class:"form-label"},"Username *",-1)),i(e("input",{"onUpdate:modelValue":o[2]||(o[2]=s=>l.createForm.username=s),type:"text",id:"username",class:"form-control",required:""},null,512),[[y,l.createForm.username]])]),e("div",$,[o[21]||(o[21]=e("label",{for:"email",class:"form-label"},"Email *",-1)),i(e("input",{"onUpdate:modelValue":o[3]||(o[3]=s=>l.createForm.email=s),type:"email",id:"email",class:"form-control",required:""},null,512),[[y,l.createForm.email]])]),e("div",ee,[o[22]||(o[22]=e("label",{for:"password",class:"form-label"},"Password *",-1)),i(e("input",{"onUpdate:modelValue":o[4]||(o[4]=s=>l.createForm.password=s),type:"password",id:"password",class:"form-control",required:"",minlength:"8"},null,512),[[y,l.createForm.password]])]),e("div",oe,[o[24]||(o[24]=e("label",{for:"role",class:"form-label"},"Role *",-1)),i(e("select",{"onUpdate:modelValue":o[5]||(o[5]=s=>l.createForm.role=s),id:"role",class:"form-control",required:""},[...o[23]||(o[23]=[e("option",{value:"admin"},"Admin",-1),e("option",{value:"operator"},"Operator",-1),e("option",{value:"viewer"},"Viewer",-1)])],512),[[k,l.createForm.role]])]),e("div",le,[e("button",{type:"button",onClick:o[6]||(o[6]=s=>l.showCreateModal=!1),class:"btn btn-outline"}," Cancel "),e("button",{type:"submit",class:"btn btn-primary",disabled:l.creating},n(l.creating?"Creating...":"Create User"),9,se)])],32)])])):F("",!0),l.showEditModal?(r(),a("div",{key:1,class:"modal-overlay",onClick:o[15]||(o[15]=g(s=>l.showEditModal=!1,["self"]))},[e("div",te,[e("div",ae,[o[25]||(o[25]=e("h3",null,"Edit User",-1)),e("button",{onClick:o[9]||(o[9]=s=>l.showEditModal=!1),class:"modal-close"},"×")]),e("form",{onSubmit:o[14]||(o[14]=g((...s)=>l.updateUser&&l.updateUser(...s),["prevent"])),class:"modal-body"},[e("div",re,[o[26]||(o[26]=e("label",{class:"form-label"},"Username",-1)),e("input",{value:l.editForm.username,type:"text",class:"form-control",disabled:""},null,8,ne)]),e("div",de,[o[27]||(o[27]=e("label",{for:"edit-email",class:"form-label"},"Email *",-1)),i(e("input",{"onUpdate:modelValue":o[10]||(o[10]=s=>l.editForm.email=s),type:"email",id:"edit-email",class:"form-control",required:""},null,512),[[y,l.editForm.email]])]),e("div",ie,[o[29]||(o[29]=e("label",{for:"edit-role",class:"form-label"},"Role *",-1)),i(e("select",{"onUpdate:modelValue":o[11]||(o[11]=s=>l.editForm.role=s),id:"edit-role",class:"form-control",required:""},[...o[28]||(o[28]=[e("option",{value:"admin"},"Admin",-1),e("option",{value:"operator"},"Operator",-1),e("option",{value:"viewer"},"Viewer",-1)])],512),[[k,l.editForm.role]])]),e("div",me,[e("label",ue,[i(e("input",{"onUpdate:modelValue":o[12]||(o[12]=s=>l.editForm.is_active=s),type:"checkbox"},null,512),[[B,l.editForm.is_active]]),o[30]||(o[30]=N(" Active ",-1))])]),e("div",ce,[e("button",{type:"button",onClick:o[13]||(o[13]=s=>l.showEditModal=!1),class:"btn btn-outline"}," Cancel "),e("button",{type:"submit",class:"btn btn-primary",disabled:l.updating},n(l.updating?"Updating...":"Update User"),9,ve)])],32)])])):F("",!0)])}const pe=D(T,[["render",fe],["__scopeId","data-v-ddf646d5"]]);export{pe as default}; diff --git a/src/frontend/dist/assets/Users-DhCK51Mf.css b/src/frontend/dist/assets/Users-DhCK51Mf.css new file mode 100644 index 0000000..2b78403 --- /dev/null +++ b/src/frontend/dist/assets/Users-DhCK51Mf.css @@ -0,0 +1 @@ +.modal-overlay[data-v-ddf646d5]{position:fixed;top:0;left:0;right:0;bottom:0;background-color:#00000080;display:flex;align-items:center;justify-content:center;z-index:1000}.modal-content[data-v-ddf646d5]{background-color:var(--card-bg);border-radius:.5rem;width:90%;max-width:500px;max-height:90vh;overflow-y:auto}.modal-header[data-v-ddf646d5]{display:flex;justify-content:space-between;align-items:center;padding:1.5rem;border-bottom:1px solid var(--border-color)}.modal-header h3[data-v-ddf646d5]{margin:0;color:var(--text-primary)}.modal-close[data-v-ddf646d5]{background:none;border:none;font-size:1.5rem;cursor:pointer;color:var(--text-secondary);padding:0;width:2rem;height:2rem;display:flex;align-items:center;justify-content:center}.modal-close[data-v-ddf646d5]:hover{color:var(--text-primary)}.modal-body[data-v-ddf646d5]{padding:1.5rem}.modal-footer[data-v-ddf646d5]{display:flex;justify-content:flex-end;gap:.5rem;margin-top:1.5rem}.form-group[data-v-ddf646d5]{margin-bottom:1rem}.form-label[data-v-ddf646d5]{display:block;margin-bottom:.5rem;font-weight:500;color:var(--text-secondary)}.form-control[data-v-ddf646d5]{width:100%;padding:.5rem;border:1px solid var(--border-color);border-radius:.25rem;background-color:var(--background);color:var(--text-primary)}.form-control[data-v-ddf646d5]:focus{outline:none;border-color:var(--primary-color)}.btn-sm[data-v-ddf646d5]{padding:.25rem .5rem;font-size:.875rem} diff --git a/src/frontend/dist/assets/VMDetails-DmFqDTd6.js b/src/frontend/dist/assets/VMDetails-DmFqDTd6.js new file mode 100644 index 0000000..863968c --- /dev/null +++ b/src/frontend/dist/assets/VMDetails-DmFqDTd6.js @@ -0,0 +1 @@ +import{_ as a,c as t,o,a as e}from"./index-DpP9cWht.js";const c={name:"VMDetails"},r={class:"vmdetails-page"};function l(n,s,i,d,p,m){return o(),t("div",r,[...s[0]||(s[0]=[e("div",{class:"card"},[e("div",{class:"card-header"},[e("h3",null,"VMDetails")]),e("p",{class:"text-muted"},"VMDetails implementation goes here")],-1)])])}const f=a(c,[["render",l]]);export{f as default}; diff --git a/src/frontend/dist/assets/VirtualMachines-B0qwUkBJ.js b/src/frontend/dist/assets/VirtualMachines-B0qwUkBJ.js new file mode 100644 index 0000000..3233274 --- /dev/null +++ b/src/frontend/dist/assets/VirtualMachines-B0qwUkBJ.js @@ -0,0 +1 @@ +import{_ as O,g as K,c as r,o as a,a as s,d as f,h as P,i as z,b as v,m as B,t as i,F as U,p as j,e as E,v as G,q as W,w as H,s as J,u as Q,x as X,r as y,j as Y,y as Z,k as $,l as D}from"./index-DpP9cWht.js";const tt={name:"VirtualMachines",setup(){const h=J(),e=Q(),c=X(),t=y([]),F=y(!1),C=y("vmid"),b=y("asc"),M=y(h.query.status||null),k=y(!1),u=y(null),V=y(""),m=async()=>{F.value=!0;try{const l=await D.vms.list();t.value=l.data,g()}catch(l){console.error("Failed to fetch VMs:",l)}finally{F.value=!1}},o=l=>{C.value===l?b.value=b.value==="asc"?"desc":"asc":(C.value=l,b.value="asc"),g()},g=()=>{t.value.sort((l,d)=>{let n=l[C.value],w=d[C.value];return typeof n=="string"&&(n=n.toLowerCase(),w=w.toLowerCase()),b.value==="asc"?n>w?1:nw?-1:0})},x=async(l,d)=>{try{await D.vms.startByVmid(l,d),c.success(`VM ${l} started successfully`),setTimeout(m,1e3)}catch(n){console.error("Failed to start VM:",n),c.error("Failed to start VM")}},T=async(l,d)=>{try{await D.vms.stopByVmid(l,d),c.success(`VM ${l} stopped successfully`),setTimeout(m,1e3)}catch(n){console.error("Failed to stop VM:",n),c.error("Failed to stop VM")}},_=async(l,d)=>{if(confirm(`Are you sure you want to power off VM ${l}? This is equivalent to pulling the power plug.`))try{await D.vms.powerOffByVmid(l,d),c.success(`VM ${l} powered off successfully`),setTimeout(m,1e3)}catch(n){console.error("Failed to power off VM:",n),c.error("Failed to power off VM")}},I=async(l,d)=>{try{await D.vms.restartByVmid(l,d),c.success(`VM ${l} restarting...`),setTimeout(m,1e3)}catch(n){console.error("Failed to restart VM:",n),c.error("Failed to restart VM")}},S=l=>{u.value=l,V.value="",k.value=!0},p=()=>{k.value=!1,u.value=null,V.value=""},N=async()=>{var l,d;if(V.value!==String(u.value.vmid)){c.error("VM ID confirmation does not match");return}try{await D.vms.deleteByVmid(u.value.vmid,u.value.node),c.success(`VM ${u.value.vmid} deleted successfully`),p(),m()}catch(n){console.error("Failed to delete VM:",n),c.error(`Failed to delete VM: ${((d=(l=n.response)==null?void 0:l.data)==null?void 0:d.detail)||n.message}`)}},L=l=>{if(!l)return"0 B";const d=["B","KB","MB","GB","TB"],n=Math.floor(Math.log(l)/Math.log(1024));return Math.round(l/Math.pow(1024,n)*100)/100+" "+d[n]},R=l=>({running:"badge-success",stopped:"badge-danger",paused:"badge-warning",suspended:"badge-warning",unknown:"badge-secondary"})[l.toLowerCase()]||"badge-info",q=Y(()=>M.value?t.value.filter(l=>l.status.toLowerCase()===M.value.toLowerCase()):t.value),A=()=>{M.value=null,e.push("/vms")};return Z(()=>h.query.status,l=>{M.value=l||null}),$(()=>{m();const l=setInterval(m,3e4);return()=>clearInterval(l)}),{vms:t,loading:F,sortField:C,sortDirection:b,statusFilter:M,filteredVMs:q,showDeleteConfirmModal:k,vmToDelete:u,deleteConfirmInput:V,sortBy:o,startVM:x,stopVM:T,powerOffVM:_,restartVM:I,showDeleteModal:S,closeDeleteModal:p,deleteVM:N,formatBytes:L,getStatusBadgeClass:R,clearFilter:A}}},et={class:"vms-page"},st={class:"card"},ot={class:"card-header"},lt={key:0,class:"filter-bar"},nt={class:"filter-info"},rt={class:"filter-count"},at={key:1,class:"loading-spinner"},it={key:2,class:"text-center text-muted"},dt={key:3,class:"text-center text-muted"},ct={key:4,class:"table-container"},ut={class:"table"},mt={key:0,class:"sort-indicator"},ft={key:0,class:"sort-indicator"},Mt={key:0,class:"sort-indicator"},vt={key:0,class:"sort-indicator"},yt={class:"badge badge-info"},bt={class:"text-sm"},Vt={class:"flex gap-1"},gt=["onClick"],Ct=["onClick"],kt=["onClick"],wt=["onClick"],Dt=["onClick"],Ft={class:"modal-header"},ht={class:"modal-body"},pt={class:"text-danger mb-1"},Bt={class:"form-group"},xt=["placeholder"],Tt={class:"modal-footer"},_t=["disabled"];function It(h,e,c,t,F,C){var M,k,u,V,m;const b=K("router-link");return a(),r("div",et,[s("div",st,[s("div",ot,[e[14]||(e[14]=s("h3",null,"Virtual Machines",-1)),P(b,{to:"/vms/create",class:"btn btn-primary"},{default:z(()=>[...e[13]||(e[13]=[v("+ Create VM",-1)])]),_:1})]),t.statusFilter?(a(),r("div",lt,[s("div",nt,[e[15]||(e[15]=s("span",{class:"filter-label"},"Filtered by status:",-1)),s("span",{class:B(["badge",t.getStatusBadgeClass(t.statusFilter)])},i(t.statusFilter),3),s("span",rt,"("+i(t.filteredVMs.length)+" VMs)",1)]),s("button",{onClick:e[0]||(e[0]=(...o)=>t.clearFilter&&t.clearFilter(...o)),class:"btn btn-sm btn-secondary"}," Clear Filter ")])):f("",!0),t.loading?(a(),r("div",at)):t.filteredVMs.length===0&&t.vms.length===0?(a(),r("div",it,[...e[16]||(e[16]=[s("p",null,"No virtual machines yet.",-1),s("p",{class:"text-sm"},"Create your first VM to get started.",-1)])])):t.filteredVMs.length===0&&t.statusFilter?(a(),r("div",dt,[s("p",null,"No "+i(t.statusFilter)+" VMs found.",1),s("button",{onClick:e[1]||(e[1]=(...o)=>t.clearFilter&&t.clearFilter(...o)),class:"btn btn-sm btn-secondary mt-1"}," Show All VMs ")])):(a(),r("div",ct,[s("table",ut,[s("thead",null,[s("tr",null,[s("th",{onClick:e[2]||(e[2]=o=>t.sortBy("vmid")),class:"sortable"},[e[17]||(e[17]=v(" VMID ",-1)),t.sortField==="vmid"?(a(),r("span",mt,i(t.sortDirection==="asc"?"▲":"▼"),1)):f("",!0)]),s("th",{onClick:e[3]||(e[3]=o=>t.sortBy("name")),class:"sortable"},[e[18]||(e[18]=v(" Name ",-1)),t.sortField==="name"?(a(),r("span",ft,i(t.sortDirection==="asc"?"▲":"▼"),1)):f("",!0)]),s("th",{onClick:e[4]||(e[4]=o=>t.sortBy("node")),class:"sortable"},[e[19]||(e[19]=v(" Node ",-1)),t.sortField==="node"?(a(),r("span",Mt,i(t.sortDirection==="asc"?"▲":"▼"),1)):f("",!0)]),e[21]||(e[21]=s("th",null,"Resources",-1)),s("th",{onClick:e[5]||(e[5]=o=>t.sortBy("status")),class:"sortable"},[e[20]||(e[20]=v(" Status ",-1)),t.sortField==="status"?(a(),r("span",vt,i(t.sortDirection==="asc"?"▲":"▼"),1)):f("",!0)]),e[22]||(e[22]=s("th",null,"Actions",-1))])]),s("tbody",null,[(a(!0),r(U,null,j(t.filteredVMs,o=>(a(),r("tr",{key:o.vmid},[s("td",null,[s("strong",null,i(o.vmid),1)]),s("td",null,i(o.name),1),s("td",null,[s("span",yt,i(o.node),1)]),s("td",bt,i(o.cpus)+" CPU / "+i(t.formatBytes(o.maxmem))+" RAM / "+i(t.formatBytes(o.maxdisk))+" Disk ",1),s("td",null,[s("span",{class:B(["badge",t.getStatusBadgeClass(o.status)])},i(o.status),3)]),s("td",null,[s("div",Vt,[o.status==="running"?(a(),r("button",{key:0,onClick:g=>t.stopVM(o.vmid,o.node),class:"btn btn-warning btn-sm"}," Stop ",8,gt)):f("",!0),o.status==="running"?(a(),r("button",{key:1,onClick:g=>t.restartVM(o.vmid,o.node),class:"btn btn-info btn-sm"}," Restart ",8,Ct)):f("",!0),o.status==="running"?(a(),r("button",{key:2,onClick:g=>t.powerOffVM(o.vmid,o.node),class:"btn btn-danger btn-sm"}," Power Off ",8,kt)):f("",!0),o.status==="stopped"?(a(),r("button",{key:3,onClick:g=>t.startVM(o.vmid,o.node),class:"btn btn-primary btn-sm"}," Start ",8,wt)):f("",!0),s("button",{onClick:g=>t.showDeleteModal(o),class:"btn btn-danger btn-sm",title:"Delete VM"}," Delete ",8,Dt)])])]))),128))])])]))]),t.showDeleteConfirmModal?(a(),r("div",{key:0,class:"modal-overlay",onClick:e[12]||(e[12]=(...o)=>t.closeDeleteModal&&t.closeDeleteModal(...o))},[s("div",{class:"modal-content",onClick:e[11]||(e[11]=H(()=>{},["stop"]))},[s("div",Ft,[e[23]||(e[23]=s("h3",null,"Confirm VM Deletion",-1)),s("button",{onClick:e[6]||(e[6]=(...o)=>t.closeDeleteModal&&t.closeDeleteModal(...o)),class:"btn-close"},"×")]),s("div",ht,[s("p",pt,[e[24]||(e[24]=s("strong",null,"Warning:",-1)),v(" This will permanently delete VM "+i((M=t.vmToDelete)==null?void 0:M.vmid)+" ("+i((k=t.vmToDelete)==null?void 0:k.name)+") from Proxmox. ",1)]),e[27]||(e[27]=s("p",{class:"text-muted mb-2"},"This action cannot be undone.",-1)),s("div",Bt,[s("label",null,[e[25]||(e[25]=v("Type the VM ID ",-1)),s("strong",null,i((u=t.vmToDelete)==null?void 0:u.vmid),1),e[26]||(e[26]=v(" to confirm:",-1))]),E(s("input",{"onUpdate:modelValue":e[7]||(e[7]=o=>t.deleteConfirmInput=o),type:"text",class:"form-control",placeholder:`Type ${(V=t.vmToDelete)==null?void 0:V.vmid} to confirm`,onKeyup:e[8]||(e[8]=W((...o)=>t.deleteVM&&t.deleteVM(...o),["enter"]))},null,40,xt),[[G,t.deleteConfirmInput]])])]),s("div",Tt,[s("button",{onClick:e[9]||(e[9]=(...o)=>t.closeDeleteModal&&t.closeDeleteModal(...o)),class:"btn btn-secondary"},"Cancel"),s("button",{onClick:e[10]||(e[10]=(...o)=>t.deleteVM&&t.deleteVM(...o)),class:"btn btn-danger",disabled:t.deleteConfirmInput!==String((m=t.vmToDelete)==null?void 0:m.vmid)}," Delete VM ",8,_t)])])])):f("",!0)])}const Nt=O(tt,[["render",It],["__scopeId","data-v-872f930a"]]);export{Nt as default}; diff --git a/src/frontend/dist/assets/VirtualMachines-B7suhB22.css b/src/frontend/dist/assets/VirtualMachines-B7suhB22.css new file mode 100644 index 0000000..a6c6ec3 --- /dev/null +++ b/src/frontend/dist/assets/VirtualMachines-B7suhB22.css @@ -0,0 +1 @@ +.btn-sm[data-v-872f930a]{padding:.25rem .5rem;font-size:.875rem}.sortable[data-v-872f930a]{cursor:pointer;-webkit-user-select:none;user-select:none}.sortable[data-v-872f930a]:hover{background-color:var(--background)}.sort-indicator[data-v-872f930a]{margin-left:.25rem;font-size:.75rem;opacity:.7}.filter-bar[data-v-872f930a]{display:flex;align-items:center;justify-content:space-between;padding:.75rem 1rem;background-color:var(--background);border-bottom:1px solid var(--border)}.filter-info[data-v-872f930a]{display:flex;align-items:center;gap:.5rem}.filter-label[data-v-872f930a]{font-size:.875rem;color:var(--text-muted)}.filter-count[data-v-872f930a]{font-size:.875rem;color:var(--text-muted);margin-left:.25rem}.modal-overlay[data-v-872f930a]{position:fixed;top:0;left:0;right:0;bottom:0;background-color:#00000080;display:flex;align-items:center;justify-content:center;z-index:1000}.modal-content[data-v-872f930a]{background-color:var(--surface);border-radius:.5rem;box-shadow:0 10px 25px #0003;max-width:500px;width:90%;max-height:90vh;overflow-y:auto}.modal-header[data-v-872f930a]{display:flex;justify-content:space-between;align-items:center;padding:1rem 1.5rem;border-bottom:1px solid var(--border-color)}.modal-header h3[data-v-872f930a]{margin:0;font-size:1.25rem;color:var(--text-primary)}.btn-close[data-v-872f930a]{background:none;border:none;font-size:2rem;line-height:1;color:var(--text-secondary);cursor:pointer;padding:0;width:2rem;height:2rem;display:flex;align-items:center;justify-content:center}.btn-close[data-v-872f930a]:hover{color:var(--text-primary)}.modal-body[data-v-872f930a]{padding:1.5rem}.modal-footer[data-v-872f930a]{display:flex;justify-content:flex-end;gap:.5rem;padding:1rem 1.5rem;border-top:1px solid var(--border-color)}.text-danger[data-v-872f930a]{color:#991b1b;font-weight:500}.text-muted[data-v-872f930a]{color:#475569}.mb-1[data-v-872f930a]{margin-bottom:.5rem}.mb-2[data-v-872f930a]{margin-bottom:1rem}.form-group[data-v-872f930a]{margin-top:1rem}.form-group label[data-v-872f930a]{display:block;margin-bottom:.5rem;font-weight:500;color:var(--text-primary)}.form-control[data-v-872f930a]{width:100%;padding:.5rem;border:1px solid var(--border-color);border-radius:.375rem;background-color:var(--background);color:var(--text-primary);font-size:1rem}.form-control[data-v-872f930a]:focus{outline:none;border-color:var(--primary-color)}.btn-secondary[data-v-872f930a]{background-color:var(--secondary-color);color:#fff}.btn-secondary[data-v-872f930a]:hover{opacity:.9} diff --git a/src/frontend/dist/assets/index-54kXT93y.css b/src/frontend/dist/assets/index-54kXT93y.css new file mode 100644 index 0000000..d3def8a --- /dev/null +++ b/src/frontend/dist/assets/index-54kXT93y.css @@ -0,0 +1 @@ +.Vue-Toastification__container{z-index:9999;position:fixed;padding:4px;width:600px;box-sizing:border-box;display:flex;min-height:100%;color:#fff;flex-direction:column;pointer-events:none}@media only screen and (min-width : 600px){.Vue-Toastification__container.top-left,.Vue-Toastification__container.top-right,.Vue-Toastification__container.top-center{top:1em}.Vue-Toastification__container.bottom-left,.Vue-Toastification__container.bottom-right,.Vue-Toastification__container.bottom-center{bottom:1em;flex-direction:column-reverse}.Vue-Toastification__container.top-left,.Vue-Toastification__container.bottom-left{left:1em}.Vue-Toastification__container.top-left .Vue-Toastification__toast,.Vue-Toastification__container.bottom-left .Vue-Toastification__toast{margin-right:auto}@supports not (-moz-appearance: none){.Vue-Toastification__container.top-left .Vue-Toastification__toast--rtl,.Vue-Toastification__container.bottom-left .Vue-Toastification__toast--rtl{margin-right:unset;margin-left:auto}}.Vue-Toastification__container.top-right,.Vue-Toastification__container.bottom-right{right:1em}.Vue-Toastification__container.top-right .Vue-Toastification__toast,.Vue-Toastification__container.bottom-right .Vue-Toastification__toast{margin-left:auto}@supports not (-moz-appearance: none){.Vue-Toastification__container.top-right .Vue-Toastification__toast--rtl,.Vue-Toastification__container.bottom-right .Vue-Toastification__toast--rtl{margin-left:unset;margin-right:auto}}.Vue-Toastification__container.top-center,.Vue-Toastification__container.bottom-center{left:50%;margin-left:-300px}.Vue-Toastification__container.top-center .Vue-Toastification__toast,.Vue-Toastification__container.bottom-center .Vue-Toastification__toast{margin-left:auto;margin-right:auto}}@media only screen and (max-width : 600px){.Vue-Toastification__container{width:100vw;padding:0;left:0;margin:0}.Vue-Toastification__container .Vue-Toastification__toast{width:100%}.Vue-Toastification__container.top-left,.Vue-Toastification__container.top-right,.Vue-Toastification__container.top-center{top:0}.Vue-Toastification__container.bottom-left,.Vue-Toastification__container.bottom-right,.Vue-Toastification__container.bottom-center{bottom:0;flex-direction:column-reverse}}.Vue-Toastification__toast{display:inline-flex;position:relative;max-height:800px;min-height:64px;box-sizing:border-box;margin-bottom:1rem;padding:22px 24px;border-radius:8px;box-shadow:0 1px 10px #0000001a,0 2px 15px #0000000d;justify-content:space-between;font-family:Lato,Helvetica,Roboto,Arial,sans-serif;max-width:600px;min-width:326px;pointer-events:auto;overflow:hidden;transform:translateZ(0);direction:ltr}.Vue-Toastification__toast--rtl{direction:rtl}.Vue-Toastification__toast--default{background-color:#1976d2;color:#fff}.Vue-Toastification__toast--info{background-color:#2196f3;color:#fff}.Vue-Toastification__toast--success{background-color:#4caf50;color:#fff}.Vue-Toastification__toast--error{background-color:#ff5252;color:#fff}.Vue-Toastification__toast--warning{background-color:#ffc107;color:#fff}@media only screen and (max-width : 600px){.Vue-Toastification__toast{border-radius:0;margin-bottom:.5rem}}.Vue-Toastification__toast-body{flex:1;line-height:24px;font-size:16px;word-break:break-word;white-space:pre-wrap}.Vue-Toastification__toast-component-body{flex:1}.Vue-Toastification__toast.disable-transition{animation:none!important}.Vue-Toastification__close-button{font-weight:700;font-size:24px;line-height:24px;background:transparent;outline:none;border:none;padding:0 0 0 10px;cursor:pointer;transition:.3s ease;align-items:center;color:#fff;opacity:.3;transition:visibility 0s,opacity .2s linear}.Vue-Toastification__close-button:hover,.Vue-Toastification__close-button:focus{opacity:1}.Vue-Toastification__toast:not(:hover) .Vue-Toastification__close-button.show-on-hover{opacity:0}.Vue-Toastification__toast--rtl .Vue-Toastification__close-button{padding-left:unset;padding-right:10px}@keyframes scale-x-frames{0%{transform:scaleX(1)}to{transform:scaleX(0)}}.Vue-Toastification__progress-bar{position:absolute;bottom:0;left:0;width:100%;height:5px;z-index:10000;background-color:#ffffffb3;transform-origin:left;animation:scale-x-frames linear 1 forwards}.Vue-Toastification__toast--rtl .Vue-Toastification__progress-bar{right:0;left:unset;transform-origin:right}.Vue-Toastification__icon{margin:auto 18px auto 0;background:transparent;outline:none;border:none;padding:0;transition:.3s ease;align-items:center;width:20px;height:100%}.Vue-Toastification__toast--rtl .Vue-Toastification__icon{margin:auto 0 auto 18px}@keyframes bounceInRight{0%,60%,75%,90%,to{animation-timing-function:cubic-bezier(.215,.61,.355,1)}0%{opacity:0;transform:translate3d(3000px,0,0)}60%{opacity:1;transform:translate3d(-25px,0,0)}75%{transform:translate3d(10px,0,0)}90%{transform:translate3d(-5px,0,0)}to{transform:none}}@keyframes bounceOutRight{40%{opacity:1;transform:translate3d(-20px,0,0)}to{opacity:0;transform:translate3d(1000px,0,0)}}@keyframes bounceInLeft{0%,60%,75%,90%,to{animation-timing-function:cubic-bezier(.215,.61,.355,1)}0%{opacity:0;transform:translate3d(-3000px,0,0)}60%{opacity:1;transform:translate3d(25px,0,0)}75%{transform:translate3d(-10px,0,0)}90%{transform:translate3d(5px,0,0)}to{transform:none}}@keyframes bounceOutLeft{20%{opacity:1;transform:translate3d(20px,0,0)}to{opacity:0;transform:translate3d(-2000px,0,0)}}@keyframes bounceInUp{0%,60%,75%,90%,to{animation-timing-function:cubic-bezier(.215,.61,.355,1)}0%{opacity:0;transform:translate3d(0,3000px,0)}60%{opacity:1;transform:translate3d(0,-20px,0)}75%{transform:translate3d(0,10px,0)}90%{transform:translate3d(0,-5px,0)}to{transform:translateZ(0)}}@keyframes bounceOutUp{20%{transform:translate3d(0,-10px,0)}40%,45%{opacity:1;transform:translate3d(0,20px,0)}to{opacity:0;transform:translate3d(0,-2000px,0)}}@keyframes bounceInDown{0%,60%,75%,90%,to{animation-timing-function:cubic-bezier(.215,.61,.355,1)}0%{opacity:0;transform:translate3d(0,-3000px,0)}60%{opacity:1;transform:translate3d(0,25px,0)}75%{transform:translate3d(0,-10px,0)}90%{transform:translate3d(0,5px,0)}to{transform:none}}@keyframes bounceOutDown{20%{transform:translate3d(0,10px,0)}40%,45%{opacity:1;transform:translate3d(0,-20px,0)}to{opacity:0;transform:translate3d(0,2000px,0)}}.Vue-Toastification__bounce-enter-active.top-left,.Vue-Toastification__bounce-enter-active.bottom-left{animation-name:bounceInLeft}.Vue-Toastification__bounce-enter-active.top-right,.Vue-Toastification__bounce-enter-active.bottom-right{animation-name:bounceInRight}.Vue-Toastification__bounce-enter-active.top-center{animation-name:bounceInDown}.Vue-Toastification__bounce-enter-active.bottom-center{animation-name:bounceInUp}.Vue-Toastification__bounce-leave-active:not(.disable-transition).top-left,.Vue-Toastification__bounce-leave-active:not(.disable-transition).bottom-left{animation-name:bounceOutLeft}.Vue-Toastification__bounce-leave-active:not(.disable-transition).top-right,.Vue-Toastification__bounce-leave-active:not(.disable-transition).bottom-right{animation-name:bounceOutRight}.Vue-Toastification__bounce-leave-active:not(.disable-transition).top-center{animation-name:bounceOutUp}.Vue-Toastification__bounce-leave-active:not(.disable-transition).bottom-center{animation-name:bounceOutDown}.Vue-Toastification__bounce-leave-active,.Vue-Toastification__bounce-enter-active{animation-duration:.75s;animation-fill-mode:both}.Vue-Toastification__bounce-move{transition-timing-function:ease-in-out;transition-property:all;transition-duration:.4s}@keyframes fadeOutTop{0%{transform:translateY(0);opacity:1}to{transform:translateY(-50px);opacity:0}}@keyframes fadeOutLeft{0%{transform:translate(0);opacity:1}to{transform:translate(-50px);opacity:0}}@keyframes fadeOutBottom{0%{transform:translateY(0);opacity:1}to{transform:translateY(50px);opacity:0}}@keyframes fadeOutRight{0%{transform:translate(0);opacity:1}to{transform:translate(50px);opacity:0}}@keyframes fadeInLeft{0%{transform:translate(-50px);opacity:0}to{transform:translate(0);opacity:1}}@keyframes fadeInRight{0%{transform:translate(50px);opacity:0}to{transform:translate(0);opacity:1}}@keyframes fadeInTop{0%{transform:translateY(-50px);opacity:0}to{transform:translateY(0);opacity:1}}@keyframes fadeInBottom{0%{transform:translateY(50px);opacity:0}to{transform:translateY(0);opacity:1}}.Vue-Toastification__fade-enter-active.top-left,.Vue-Toastification__fade-enter-active.bottom-left{animation-name:fadeInLeft}.Vue-Toastification__fade-enter-active.top-right,.Vue-Toastification__fade-enter-active.bottom-right{animation-name:fadeInRight}.Vue-Toastification__fade-enter-active.top-center{animation-name:fadeInTop}.Vue-Toastification__fade-enter-active.bottom-center{animation-name:fadeInBottom}.Vue-Toastification__fade-leave-active:not(.disable-transition).top-left,.Vue-Toastification__fade-leave-active:not(.disable-transition).bottom-left{animation-name:fadeOutLeft}.Vue-Toastification__fade-leave-active:not(.disable-transition).top-right,.Vue-Toastification__fade-leave-active:not(.disable-transition).bottom-right{animation-name:fadeOutRight}.Vue-Toastification__fade-leave-active:not(.disable-transition).top-center{animation-name:fadeOutTop}.Vue-Toastification__fade-leave-active:not(.disable-transition).bottom-center{animation-name:fadeOutBottom}.Vue-Toastification__fade-leave-active,.Vue-Toastification__fade-enter-active{animation-duration:.75s;animation-fill-mode:both}.Vue-Toastification__fade-move{transition-timing-function:ease-in-out;transition-property:all;transition-duration:.4s}@keyframes slideInBlurredLeft{0%{transform:translate(-1000px) scaleX(2.5) scaleY(.2);transform-origin:100% 50%;filter:blur(40px);opacity:0}to{transform:translate(0) scaleY(1) scaleX(1);transform-origin:50% 50%;filter:blur(0);opacity:1}}@keyframes slideInBlurredTop{0%{transform:translateY(-1000px) scaleY(2.5) scaleX(.2);transform-origin:50% 0%;filter:blur(240px);opacity:0}to{transform:translateY(0) scaleY(1) scaleX(1);transform-origin:50% 50%;filter:blur(0);opacity:1}}@keyframes slideInBlurredRight{0%{transform:translate(1000px) scaleX(2.5) scaleY(.2);transform-origin:0% 50%;filter:blur(40px);opacity:0}to{transform:translate(0) scaleY(1) scaleX(1);transform-origin:50% 50%;filter:blur(0);opacity:1}}@keyframes slideInBlurredBottom{0%{transform:translateY(1000px) scaleY(2.5) scaleX(.2);transform-origin:50% 100%;filter:blur(240px);opacity:0}to{transform:translateY(0) scaleY(1) scaleX(1);transform-origin:50% 50%;filter:blur(0);opacity:1}}@keyframes slideOutBlurredTop{0%{transform:translateY(0) scaleY(1) scaleX(1);transform-origin:50% 0%;filter:blur(0);opacity:1}to{transform:translateY(-1000px) scaleY(2) scaleX(.2);transform-origin:50% 0%;filter:blur(240px);opacity:0}}@keyframes slideOutBlurredBottom{0%{transform:translateY(0) scaleY(1) scaleX(1);transform-origin:50% 50%;filter:blur(0);opacity:1}to{transform:translateY(1000px) scaleY(2) scaleX(.2);transform-origin:50% 100%;filter:blur(240px);opacity:0}}@keyframes slideOutBlurredLeft{0%{transform:translate(0) scaleY(1) scaleX(1);transform-origin:50% 50%;filter:blur(0);opacity:1}to{transform:translate(-1000px) scaleX(2) scaleY(.2);transform-origin:100% 50%;filter:blur(40px);opacity:0}}@keyframes slideOutBlurredRight{0%{transform:translate(0) scaleY(1) scaleX(1);transform-origin:50% 50%;filter:blur(0);opacity:1}to{transform:translate(1000px) scaleX(2) scaleY(.2);transform-origin:0% 50%;filter:blur(40px);opacity:0}}.Vue-Toastification__slideBlurred-enter-active.top-left,.Vue-Toastification__slideBlurred-enter-active.bottom-left{animation-name:slideInBlurredLeft}.Vue-Toastification__slideBlurred-enter-active.top-right,.Vue-Toastification__slideBlurred-enter-active.bottom-right{animation-name:slideInBlurredRight}.Vue-Toastification__slideBlurred-enter-active.top-center{animation-name:slideInBlurredTop}.Vue-Toastification__slideBlurred-enter-active.bottom-center{animation-name:slideInBlurredBottom}.Vue-Toastification__slideBlurred-leave-active:not(.disable-transition).top-left,.Vue-Toastification__slideBlurred-leave-active:not(.disable-transition).bottom-left{animation-name:slideOutBlurredLeft}.Vue-Toastification__slideBlurred-leave-active:not(.disable-transition).top-right,.Vue-Toastification__slideBlurred-leave-active:not(.disable-transition).bottom-right{animation-name:slideOutBlurredRight}.Vue-Toastification__slideBlurred-leave-active:not(.disable-transition).top-center{animation-name:slideOutBlurredTop}.Vue-Toastification__slideBlurred-leave-active:not(.disable-transition).bottom-center{animation-name:slideOutBlurredBottom}.Vue-Toastification__slideBlurred-leave-active,.Vue-Toastification__slideBlurred-enter-active{animation-duration:.75s;animation-fill-mode:both}.Vue-Toastification__slideBlurred-move{transition-timing-function:ease-in-out;transition-property:all;transition-duration:.4s}.sidebar[data-v-a8b8e301]{width:250px;background:linear-gradient(180deg,#1a2332,#0f1419);color:#fff;height:100vh;position:fixed;left:0;top:0;display:flex;flex-direction:column;z-index:100;box-shadow:2px 0 10px #0000004d}.sidebar-header[data-v-a8b8e301]{padding:2rem 1.5rem;border-bottom:1px solid rgba(255,255,255,.1)}.logo[data-v-a8b8e301]{font-size:2rem;font-weight:700;margin:0;letter-spacing:-1px}.logo-zero[data-v-a8b8e301]{color:#3b82f6}.tagline[data-v-a8b8e301]{font-size:.75rem;opacity:.8;margin-top:.25rem}.sidebar-nav[data-v-a8b8e301]{flex:1;padding:1rem 0;overflow-y:auto}.nav-item[data-v-a8b8e301]{display:flex;align-items:center;padding:.75rem 1.5rem;color:#fffc;text-decoration:none;transition:all .2s;gap:.75rem}.nav-item[data-v-a8b8e301]:hover{background-color:#ffffff1a;color:#fff}.nav-item.router-link-active[data-v-a8b8e301]{background-color:#3b82f626;color:#fff;border-left:3px solid #3b82f6}.icon[data-v-a8b8e301]{font-size:1.25rem}.sidebar-footer[data-v-a8b8e301]{padding:1rem 1.5rem;border-top:1px solid rgba(255,255,255,.1);font-size:.75rem;opacity:.6}.version[data-v-a8b8e301],.copyright[data-v-a8b8e301]{margin:.25rem 0}@media (max-width: 768px){.sidebar[data-v-a8b8e301]{transform:translate(-100%)}}.header[data-v-eac4cac2]{background-color:#fff;border-bottom:1px solid var(--border-color);padding:1rem 2rem;display:flex;justify-content:space-between;align-items:center;position:sticky;top:0;z-index:50}.page-title[data-v-eac4cac2]{font-size:1.5rem;font-weight:600;color:var(--text-primary);margin:0}.header-right[data-v-eac4cac2]{display:flex;align-items:center;gap:1.5rem}.user-info[data-v-eac4cac2]{display:flex;align-items:center;gap:.75rem}.username[data-v-eac4cac2]{font-weight:500;color:var(--text-primary)}@media (max-width: 768px){.header[data-v-eac4cac2]{padding:1rem}.page-title[data-v-eac4cac2]{font-size:1.25rem}.user-info[data-v-eac4cac2]{display:none}}.app-container[data-v-cb84523c]{display:flex;min-height:100vh;background-color:#f5f7fa}.main-content[data-v-cb84523c]{flex:1;display:flex;flex-direction:column;margin-left:250px;transition:margin-left .3s ease}.main-content.full-width[data-v-cb84523c]{margin-left:0}.content[data-v-cb84523c]{flex:1;padding:2rem;overflow-y:auto}@media (max-width: 768px){.main-content[data-v-cb84523c]{margin-left:0}.content[data-v-cb84523c]{padding:1rem}}*{margin:0;padding:0;box-sizing:border-box}:root{--primary-color: #2563eb;--primary-hover: #1d4ed8;--secondary-color: #10b981;--danger-color: #dc2626;--warning-color: #f59e0b;--background: #f8fafc;--surface: #ffffff;--text-primary: #0f172a;--text-secondary: #64748b;--border-color: #e2e8f0;--shadow: 0 1px 3px 0 rgb(0 0 0 / .1);--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / .1)}body{font-family:Segoe UI,Tahoma,Geneva,Verdana,sans-serif;font-size:14px;color:var(--text-primary);background-color:var(--background);line-height:1.6}.btn{padding:.5rem 1rem;border:none;border-radius:.375rem;font-size:.875rem;font-weight:500;cursor:pointer;transition:all .2s;display:inline-flex;align-items:center;gap:.5rem}.btn-primary{background-color:var(--primary-color);color:#fff}.btn-primary:hover{background-color:var(--primary-hover)}.btn-secondary{background-color:var(--secondary-color);color:#fff}.btn-danger{background-color:var(--danger-color);color:#fff}.btn-outline{background-color:transparent;border:1px solid var(--border-color);color:var(--text-primary)}.btn:disabled{opacity:.5;cursor:not-allowed}.card{background-color:var(--surface);border-radius:.5rem;box-shadow:var(--shadow);padding:1.5rem;margin-bottom:1.5rem}.card-header{font-size:1.25rem;font-weight:600;margin-bottom:1rem;display:flex;justify-content:space-between;align-items:center}.form-group{margin-bottom:1rem}.form-label{display:block;font-weight:500;margin-bottom:.5rem;color:var(--text-primary)}.form-control{width:100%;padding:.5rem .75rem;border:1px solid var(--border-color);border-radius:.375rem;font-size:.875rem;transition:border-color .2s}.form-control:focus{outline:none;border-color:var(--primary-color);box-shadow:0 0 0 3px #4f46e51a}select.form-control{background-color:#fff;cursor:pointer}.table-container{overflow-x:auto}.table{width:100%;border-collapse:collapse}.table th{background-color:var(--background);padding:.75rem 1rem;text-align:left;font-weight:600;color:var(--text-secondary);font-size:.75rem;text-transform:uppercase;letter-spacing:.05em}.table td{padding:.75rem 1rem;border-top:1px solid var(--border-color)}.table tbody tr:hover{background-color:var(--background)}.badge{display:inline-flex;align-items:center;padding:.25rem .75rem;border-radius:9999px;font-size:.75rem;font-weight:500}.badge-success{background-color:#d1fae5;color:#065f46}.badge-danger{background-color:#fee2e2;color:#991b1b}.badge-warning{background-color:#fef3c7;color:#92400e}.badge-info{background-color:#dbeafe;color:#1e40af}.grid{display:grid;gap:1.5rem}.grid-cols-2{grid-template-columns:repeat(2,1fr)}.grid-cols-3{grid-template-columns:repeat(3,1fr)}.grid-cols-4{grid-template-columns:repeat(4,1fr)}.flex{display:flex}.flex-between{display:flex;justify-content:space-between;align-items:center}.gap-1{gap:.5rem}.gap-2{gap:1rem}.mb-1{margin-bottom:.5rem}.mb-2{margin-bottom:1rem}.mt-1{margin-top:.5rem}.mt-2{margin-top:1rem}.text-center{text-align:center}.text-right{text-align:right}.text-sm{font-size:.875rem}.text-xs{font-size:.75rem}.text-muted{color:var(--text-secondary)}.loading-spinner{border:3px solid #f3f3f3;border-top:3px solid var(--primary-color);border-radius:50%;width:40px;height:40px;animation:spin 1s linear infinite;margin:2rem auto}@keyframes spin{0%{transform:rotate(0)}to{transform:rotate(360deg)}}@media (max-width: 768px){.grid-cols-2,.grid-cols-3,.grid-cols-4{grid-template-columns:1fr}} diff --git a/src/frontend/dist/assets/index-DpP9cWht.js b/src/frontend/dist/assets/index-DpP9cWht.js new file mode 100644 index 0000000..2f70393 --- /dev/null +++ b/src/frontend/dist/assets/index-DpP9cWht.js @@ -0,0 +1,35 @@ +const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/Login-C73ePELU.js","assets/Login-CoaePlAS.css","assets/Dashboard-D-lJZ8zJ.js","assets/Dashboard-FZCEL6lL.css","assets/VirtualMachines-B0qwUkBJ.js","assets/VirtualMachines-B7suhB22.css","assets/CreateVM-D6qAFo1d.js","assets/CreateVM-B49KqhzI.css","assets/ProxmoxHosts-ShY9t_Gh.js","assets/ProxmoxHosts-nDwxpsiW.css","assets/ISOImages-BihK9fce.js","assets/ISOImages-B9gk_FEk.css","assets/CloudImages-P8bBuEe-.js","assets/CloudImages-7wEL8gnb.css","assets/HAManagement-BUepIRUl.js","assets/HAManagement-BuZVY5gN.css","assets/Users-0cuk11p1.js","assets/Users-DhCK51Mf.css","assets/Settings-BzMSGQUg.js","assets/Settings-CLCNiZOx.css","assets/Documentation-BwVxcGBy.js","assets/Documentation-P_J1PI26.css","assets/BugReport-DLW0NuSM.js","assets/BugReport-Ct4FqCP3.css"])))=>i.map(i=>d[i]); +(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const r of document.querySelectorAll('link[rel="modulepreload"]'))s(r);new MutationObserver(r=>{for(const o of r)if(o.type==="childList")for(const i of o.addedNodes)i.tagName==="LINK"&&i.rel==="modulepreload"&&s(i)}).observe(document,{childList:!0,subtree:!0});function n(r){const o={};return r.integrity&&(o.integrity=r.integrity),r.referrerPolicy&&(o.referrerPolicy=r.referrerPolicy),r.crossOrigin==="use-credentials"?o.credentials="include":r.crossOrigin==="anonymous"?o.credentials="omit":o.credentials="same-origin",o}function s(r){if(r.ep)return;r.ep=!0;const o=n(r);fetch(r.href,o)}})();/** +* @vue/shared v3.5.24 +* (c) 2018-present Yuxi (Evan) You and Vue contributors +* @license MIT +**/function io(e){const t=Object.create(null);for(const n of e.split(","))t[n]=1;return n=>n in t}const de={},_n=[],Et=()=>{},ha=()=>!1,Ks=e=>e.charCodeAt(0)===111&&e.charCodeAt(1)===110&&(e.charCodeAt(2)>122||e.charCodeAt(2)<97),ao=e=>e.startsWith("onUpdate:"),we=Object.assign,lo=(e,t)=>{const n=e.indexOf(t);n>-1&&e.splice(n,1)},jc=Object.prototype.hasOwnProperty,ae=(e,t)=>jc.call(e,t),U=Array.isArray,bn=e=>ss(e)==="[object Map]",On=e=>ss(e)==="[object Set]",ko=e=>ss(e)==="[object Date]",Q=e=>typeof e=="function",ge=e=>typeof e=="string",it=e=>typeof e=="symbol",fe=e=>e!==null&&typeof e=="object",pa=e=>(fe(e)||Q(e))&&Q(e.then)&&Q(e.catch),ma=Object.prototype.toString,ss=e=>ma.call(e),qc=e=>ss(e).slice(8,-1),ga=e=>ss(e)==="[object Object]",co=e=>ge(e)&&e!=="NaN"&&e[0]!=="-"&&""+parseInt(e,10)===e,kn=io(",key,ref,ref_for,ref_key,onVnodeBeforeMount,onVnodeMounted,onVnodeBeforeUpdate,onVnodeUpdated,onVnodeBeforeUnmount,onVnodeUnmounted"),Ws=e=>{const t=Object.create(null);return n=>t[n]||(t[n]=e(n))},Kc=/-\w/g,tt=Ws(e=>e.replace(Kc,t=>t.slice(1).toUpperCase())),Wc=/\B([A-Z])/g,zt=Ws(e=>e.replace(Wc,"-$1").toLowerCase()),Gs=Ws(e=>e.charAt(0).toUpperCase()+e.slice(1)),vs=Ws(e=>e?`on${Gs(e)}`:""),qt=(e,t)=>!Object.is(e,t),Es=(e,...t)=>{for(let n=0;n{Object.defineProperty(e,t,{configurable:!0,enumerable:!1,writable:s,value:n})},zs=e=>{const t=parseFloat(e);return isNaN(t)?e:t},Gc=e=>{const t=ge(e)?Number(e):NaN;return isNaN(t)?e:t};let Vo;const Js=()=>Vo||(Vo=typeof globalThis<"u"?globalThis:typeof self<"u"?self:typeof window<"u"?window:typeof global<"u"?global:{});function rs(e){if(U(e)){const t={};for(let n=0;n{if(n){const s=n.split(Jc);s.length>1&&(t[s[0].trim()]=s[1].trim())}}),t}function Nt(e){let t="";if(ge(e))t=e;else if(U(e))for(let n=0;nun(n,t))}const ba=e=>!!(e&&e.__v_isRef===!0),sn=e=>ge(e)?e:e==null?"":U(e)||fe(e)&&(e.toString===ma||!Q(e.toString))?ba(e)?sn(e.value):JSON.stringify(e,va,2):String(e),va=(e,t)=>ba(t)?va(e,t.value):bn(t)?{[`Map(${t.size})`]:[...t.entries()].reduce((n,[s,r],o)=>(n[yr(s,o)+" =>"]=r,n),{})}:On(t)?{[`Set(${t.size})`]:[...t.values()].map(n=>yr(n))}:it(t)?yr(t):fe(t)&&!U(t)&&!ga(t)?String(t):t,yr=(e,t="")=>{var n;return it(e)?`Symbol(${(n=e.description)!=null?n:t})`:e};/** +* @vue/reactivity v3.5.24 +* (c) 2018-present Yuxi (Evan) You and Vue contributors +* @license MIT +**/let xe;class Ea{constructor(t=!1){this.detached=t,this._active=!0,this._on=0,this.effects=[],this.cleanups=[],this._isPaused=!1,this.parent=xe,!t&&xe&&(this.index=(xe.scopes||(xe.scopes=[])).push(this)-1)}get active(){return this._active}pause(){if(this._active){this._isPaused=!0;let t,n;if(this.scopes)for(t=0,n=this.scopes.length;t0&&--this._on===0&&(xe=this.prevScope,this.prevScope=void 0)}stop(t){if(this._active){this._active=!1;let n,s;for(n=0,s=this.effects.length;n0)return;if($n){let t=$n;for($n=void 0;t;){const n=t.next;t.next=void 0,t.flags&=-9,t=n}}let e;for(;Vn;){let t=Vn;for(Vn=void 0;t;){const n=t.next;if(t.next=void 0,t.flags&=-9,t.flags&1)try{t.trigger()}catch(s){e||(e=s)}t=n}}if(e)throw e}function Ra(e){for(let t=e.deps;t;t=t.nextDep)t.version=-1,t.prevActiveLink=t.dep.activeLink,t.dep.activeLink=t}function Oa(e){let t,n=e.depsTail,s=n;for(;s;){const r=s.prevDep;s.version===-1?(s===n&&(n=r),po(s),nu(s)):t=s,s.dep.activeLink=s.prevActiveLink,s.prevActiveLink=void 0,s=r}e.deps=t,e.depsTail=n}function Br(e){for(let t=e.deps;t;t=t.nextDep)if(t.dep.version!==t.version||t.dep.computed&&(xa(t.dep.computed)||t.dep.version!==t.version))return!0;return!!e._dirty}function xa(e){if(e.flags&4&&!(e.flags&16)||(e.flags&=-17,e.globalVersion===zn)||(e.globalVersion=zn,!e.isSSR&&e.flags&128&&(!e.deps&&!e._dirty||!Br(e))))return;e.flags|=2;const t=e.dep,n=pe,s=ot;pe=e,ot=!0;try{Ra(e);const r=e.fn(e._value);(t.version===0||qt(r,e._value))&&(e.flags|=128,e._value=r,t.version++)}catch(r){throw t.version++,r}finally{pe=n,ot=s,Oa(e),e.flags&=-3}}function po(e,t=!1){const{dep:n,prevSub:s,nextSub:r}=e;if(s&&(s.nextSub=r,e.prevSub=void 0),r&&(r.prevSub=s,e.nextSub=void 0),n.subs===e&&(n.subs=s,!s&&n.computed)){n.computed.flags&=-5;for(let o=n.computed.deps;o;o=o.nextDep)po(o,!0)}!t&&!--n.sc&&n.map&&n.map.delete(n.key)}function nu(e){const{prevDep:t,nextDep:n}=e;t&&(t.nextDep=n,e.prevDep=void 0),n&&(n.prevDep=t,e.nextDep=void 0)}let ot=!0;const Pa=[];function Dt(){Pa.push(ot),ot=!1}function Lt(){const e=Pa.pop();ot=e===void 0?!0:e}function $o(e){const{cleanup:t}=e;if(e.cleanup=void 0,t){const n=pe;pe=void 0;try{t()}finally{pe=n}}}let zn=0;class su{constructor(t,n){this.sub=t,this.dep=n,this.version=n.version,this.nextDep=this.prevDep=this.nextSub=this.prevSub=this.prevActiveLink=void 0}}class mo{constructor(t){this.computed=t,this.version=0,this.activeLink=void 0,this.subs=void 0,this.map=void 0,this.key=void 0,this.sc=0,this.__v_skip=!0}track(t){if(!pe||!ot||pe===this.computed)return;let n=this.activeLink;if(n===void 0||n.sub!==pe)n=this.activeLink=new su(pe,this),pe.deps?(n.prevDep=pe.depsTail,pe.depsTail.nextDep=n,pe.depsTail=n):pe.deps=pe.depsTail=n,Ia(n);else if(n.version===-1&&(n.version=this.version,n.nextDep)){const s=n.nextDep;s.prevDep=n.prevDep,n.prevDep&&(n.prevDep.nextDep=s),n.prevDep=pe.depsTail,n.nextDep=void 0,pe.depsTail.nextDep=n,pe.depsTail=n,pe.deps===n&&(pe.deps=s)}return n}trigger(t){this.version++,zn++,this.notify(t)}notify(t){fo();try{for(let n=this.subs;n;n=n.prevSub)n.sub.notify()&&n.sub.dep.notify()}finally{ho()}}}function Ia(e){if(e.dep.sc++,e.sub.flags&4){const t=e.dep.computed;if(t&&!e.dep.subs){t.flags|=20;for(let s=t.deps;s;s=s.nextDep)Ia(s)}const n=e.dep.subs;n!==e&&(e.prevSub=n,n&&(n.nextSub=e)),e.dep.subs=e}}const Ns=new WeakMap,rn=Symbol(""),Fr=Symbol(""),Jn=Symbol("");function Pe(e,t,n){if(ot&&pe){let s=Ns.get(e);s||Ns.set(e,s=new Map);let r=s.get(n);r||(s.set(n,r=new mo),r.map=s,r.key=n),r.track()}}function Rt(e,t,n,s,r,o){const i=Ns.get(e);if(!i){zn++;return}const a=l=>{l&&l.trigger()};if(fo(),t==="clear")i.forEach(a);else{const l=U(e),u=l&&co(n);if(l&&n==="length"){const c=Number(s);i.forEach((f,p)=>{(p==="length"||p===Jn||!it(p)&&p>=c)&&a(f)})}else switch((n!==void 0||i.has(void 0))&&a(i.get(n)),u&&a(i.get(Jn)),t){case"add":l?u&&a(i.get("length")):(a(i.get(rn)),bn(e)&&a(i.get(Fr)));break;case"delete":l||(a(i.get(rn)),bn(e)&&a(i.get(Fr)));break;case"set":bn(e)&&a(i.get(rn));break}}ho()}function ru(e,t){const n=Ns.get(e);return n&&n.get(t)}function hn(e){const t=oe(e);return t===e?t:(Pe(t,"iterate",Jn),Ze(e)?t:t.map(Ce))}function Xs(e){return Pe(e=oe(e),"iterate",Jn),e}const ou={__proto__:null,[Symbol.iterator](){return br(this,Symbol.iterator,Ce)},concat(...e){return hn(this).concat(...e.map(t=>U(t)?hn(t):t))},entries(){return br(this,"entries",e=>(e[1]=Ce(e[1]),e))},every(e,t){return At(this,"every",e,t,void 0,arguments)},filter(e,t){return At(this,"filter",e,t,n=>n.map(Ce),arguments)},find(e,t){return At(this,"find",e,t,Ce,arguments)},findIndex(e,t){return At(this,"findIndex",e,t,void 0,arguments)},findLast(e,t){return At(this,"findLast",e,t,Ce,arguments)},findLastIndex(e,t){return At(this,"findLastIndex",e,t,void 0,arguments)},forEach(e,t){return At(this,"forEach",e,t,void 0,arguments)},includes(...e){return vr(this,"includes",e)},indexOf(...e){return vr(this,"indexOf",e)},join(e){return hn(this).join(e)},lastIndexOf(...e){return vr(this,"lastIndexOf",e)},map(e,t){return At(this,"map",e,t,void 0,arguments)},pop(){return Nn(this,"pop")},push(...e){return Nn(this,"push",e)},reduce(e,...t){return Uo(this,"reduce",e,t)},reduceRight(e,...t){return Uo(this,"reduceRight",e,t)},shift(){return Nn(this,"shift")},some(e,t){return At(this,"some",e,t,void 0,arguments)},splice(...e){return Nn(this,"splice",e)},toReversed(){return hn(this).toReversed()},toSorted(e){return hn(this).toSorted(e)},toSpliced(...e){return hn(this).toSpliced(...e)},unshift(...e){return Nn(this,"unshift",e)},values(){return br(this,"values",Ce)}};function br(e,t,n){const s=Xs(e),r=s[t]();return s!==e&&!Ze(e)&&(r._next=r.next,r.next=()=>{const o=r._next();return o.done||(o.value=n(o.value)),o}),r}const iu=Array.prototype;function At(e,t,n,s,r,o){const i=Xs(e),a=i!==e&&!Ze(e),l=i[t];if(l!==iu[t]){const f=l.apply(e,o);return a?Ce(f):f}let u=n;i!==e&&(a?u=function(f,p){return n.call(this,Ce(f),p,e)}:n.length>2&&(u=function(f,p){return n.call(this,f,p,e)}));const c=l.call(i,u,s);return a&&r?r(c):c}function Uo(e,t,n,s){const r=Xs(e);let o=n;return r!==e&&(Ze(e)?n.length>3&&(o=function(i,a,l){return n.call(this,i,a,l,e)}):o=function(i,a,l){return n.call(this,i,Ce(a),l,e)}),r[t](o,...s)}function vr(e,t,n){const s=oe(e);Pe(s,"iterate",Jn);const r=s[t](...n);return(r===-1||r===!1)&&_o(n[0])?(n[0]=oe(n[0]),s[t](...n)):r}function Nn(e,t,n=[]){Dt(),fo();const s=oe(e)[t].apply(e,n);return ho(),Lt(),s}const au=io("__proto__,__v_isRef,__isVue"),Na=new Set(Object.getOwnPropertyNames(Symbol).filter(e=>e!=="arguments"&&e!=="caller").map(e=>Symbol[e]).filter(it));function lu(e){it(e)||(e=String(e));const t=oe(this);return Pe(t,"has",e),t.hasOwnProperty(e)}class Da{constructor(t=!1,n=!1){this._isReadonly=t,this._isShallow=n}get(t,n,s){if(n==="__v_skip")return t.__v_skip;const r=this._isReadonly,o=this._isShallow;if(n==="__v_isReactive")return!r;if(n==="__v_isReadonly")return r;if(n==="__v_isShallow")return o;if(n==="__v_raw")return s===(r?o?_u:Fa:o?Ba:Ma).get(t)||Object.getPrototypeOf(t)===Object.getPrototypeOf(s)?t:void 0;const i=U(t);if(!r){let l;if(i&&(l=ou[n]))return l;if(n==="hasOwnProperty")return lu}const a=Reflect.get(t,n,ve(t)?t:s);if((it(n)?Na.has(n):au(n))||(r||Pe(t,"get",n),o))return a;if(ve(a)){const l=i&&co(n)?a:a.value;return r&&fe(l)?Vr(l):l}return fe(a)?r?Vr(a):os(a):a}}class La extends Da{constructor(t=!1){super(!1,t)}set(t,n,s,r){let o=t[n];if(!this._isShallow){const l=Wt(o);if(!Ze(s)&&!Wt(s)&&(o=oe(o),s=oe(s)),!U(t)&&ve(o)&&!ve(s))return l||(o.value=s),!0}const i=U(t)&&co(n)?Number(n)e,ps=e=>Reflect.getPrototypeOf(e);function hu(e,t,n){return function(...s){const r=this.__v_raw,o=oe(r),i=bn(o),a=e==="entries"||e===Symbol.iterator&&i,l=e==="keys"&&i,u=r[e](...s),c=n?kr:t?Ds:Ce;return!t&&Pe(o,"iterate",l?Fr:rn),{next(){const{value:f,done:p}=u.next();return p?{value:f,done:p}:{value:a?[c(f[0]),c(f[1])]:c(f),done:p}},[Symbol.iterator](){return this}}}}function ms(e){return function(...t){return e==="delete"?!1:e==="clear"?void 0:this}}function pu(e,t){const n={get(r){const o=this.__v_raw,i=oe(o),a=oe(r);e||(qt(r,a)&&Pe(i,"get",r),Pe(i,"get",a));const{has:l}=ps(i),u=t?kr:e?Ds:Ce;if(l.call(i,r))return u(o.get(r));if(l.call(i,a))return u(o.get(a));o!==i&&o.get(r)},get size(){const r=this.__v_raw;return!e&&Pe(oe(r),"iterate",rn),r.size},has(r){const o=this.__v_raw,i=oe(o),a=oe(r);return e||(qt(r,a)&&Pe(i,"has",r),Pe(i,"has",a)),r===a?o.has(r):o.has(r)||o.has(a)},forEach(r,o){const i=this,a=i.__v_raw,l=oe(a),u=t?kr:e?Ds:Ce;return!e&&Pe(l,"iterate",rn),a.forEach((c,f)=>r.call(o,u(c),u(f),i))}};return we(n,e?{add:ms("add"),set:ms("set"),delete:ms("delete"),clear:ms("clear")}:{add(r){!t&&!Ze(r)&&!Wt(r)&&(r=oe(r));const o=oe(this);return ps(o).has.call(o,r)||(o.add(r),Rt(o,"add",r,r)),this},set(r,o){!t&&!Ze(o)&&!Wt(o)&&(o=oe(o));const i=oe(this),{has:a,get:l}=ps(i);let u=a.call(i,r);u||(r=oe(r),u=a.call(i,r));const c=l.call(i,r);return i.set(r,o),u?qt(o,c)&&Rt(i,"set",r,o):Rt(i,"add",r,o),this},delete(r){const o=oe(this),{has:i,get:a}=ps(o);let l=i.call(o,r);l||(r=oe(r),l=i.call(o,r)),a&&a.call(o,r);const u=o.delete(r);return l&&Rt(o,"delete",r,void 0),u},clear(){const r=oe(this),o=r.size!==0,i=r.clear();return o&&Rt(r,"clear",void 0,void 0),i}}),["keys","values","entries",Symbol.iterator].forEach(r=>{n[r]=hu(r,e,t)}),n}function go(e,t){const n=pu(e,t);return(s,r,o)=>r==="__v_isReactive"?!e:r==="__v_isReadonly"?e:r==="__v_raw"?s:Reflect.get(ae(n,r)&&r in s?n:s,r,o)}const mu={get:go(!1,!1)},gu={get:go(!1,!0)},yu={get:go(!0,!1)};const Ma=new WeakMap,Ba=new WeakMap,Fa=new WeakMap,_u=new WeakMap;function bu(e){switch(e){case"Object":case"Array":return 1;case"Map":case"Set":case"WeakMap":case"WeakSet":return 2;default:return 0}}function vu(e){return e.__v_skip||!Object.isExtensible(e)?0:bu(qc(e))}function os(e){return Wt(e)?e:yo(e,!1,uu,mu,Ma)}function ka(e){return yo(e,!1,du,gu,Ba)}function Vr(e){return yo(e,!0,fu,yu,Fa)}function yo(e,t,n,s,r){if(!fe(e)||e.__v_raw&&!(t&&e.__v_isReactive))return e;const o=vu(e);if(o===0)return e;const i=r.get(e);if(i)return i;const a=new Proxy(e,o===2?s:n);return r.set(e,a),a}function Kt(e){return Wt(e)?Kt(e.__v_raw):!!(e&&e.__v_isReactive)}function Wt(e){return!!(e&&e.__v_isReadonly)}function Ze(e){return!!(e&&e.__v_isShallow)}function _o(e){return e?!!e.__v_raw:!1}function oe(e){const t=e&&e.__v_raw;return t?oe(t):e}function bo(e){return!ae(e,"__v_skip")&&Object.isExtensible(e)&&ya(e,"__v_skip",!0),e}const Ce=e=>fe(e)?os(e):e,Ds=e=>fe(e)?Vr(e):e;function ve(e){return e?e.__v_isRef===!0:!1}function Qs(e){return Va(e,!1)}function Eu(e){return Va(e,!0)}function Va(e,t){return ve(e)?e:new Su(e,t)}class Su{constructor(t,n){this.dep=new mo,this.__v_isRef=!0,this.__v_isShallow=!1,this._rawValue=n?t:oe(t),this._value=n?t:Ce(t),this.__v_isShallow=n}get value(){return this.dep.track(),this._value}set value(t){const n=this._rawValue,s=this.__v_isShallow||Ze(t)||Wt(t);t=s?t:oe(t),qt(t,n)&&(this._rawValue=t,this._value=s?t:Ce(t),this.dep.trigger())}}function on(e){return ve(e)?e.value:e}const wu={get:(e,t,n)=>t==="__v_raw"?e:on(Reflect.get(e,t,n)),set:(e,t,n,s)=>{const r=e[t];return ve(r)&&!ve(n)?(r.value=n,!0):Reflect.set(e,t,n,s)}};function $a(e){return Kt(e)?e:new Proxy(e,wu)}function Au(e){const t=U(e)?new Array(e.length):{};for(const n in e)t[n]=Tu(e,n);return t}class Cu{constructor(t,n,s){this._object=t,this._key=n,this._defaultValue=s,this.__v_isRef=!0,this._value=void 0}get value(){const t=this._object[this._key];return this._value=t===void 0?this._defaultValue:t}set value(t){this._object[this._key]=t}get dep(){return ru(oe(this._object),this._key)}}function Tu(e,t,n){const s=e[t];return ve(s)?s:new Cu(e,t,n)}class Ru{constructor(t,n,s){this.fn=t,this.setter=n,this._value=void 0,this.dep=new mo(this),this.__v_isRef=!0,this.deps=void 0,this.depsTail=void 0,this.flags=16,this.globalVersion=zn-1,this.next=void 0,this.effect=this,this.__v_isReadonly=!n,this.isSSR=s}notify(){if(this.flags|=16,!(this.flags&8)&&pe!==this)return Ta(this,!0),!0}get value(){const t=this.dep.track();return xa(this),t&&(t.version=this.dep.version),this._value}set value(t){this.setter&&this.setter(t)}}function Ou(e,t,n=!1){let s,r;return Q(e)?s=e:(s=e.get,r=e.set),new Ru(s,r,n)}const gs={},Ls=new WeakMap;let Yt;function xu(e,t=!1,n=Yt){if(n){let s=Ls.get(n);s||Ls.set(n,s=[]),s.push(e)}}function Pu(e,t,n=de){const{immediate:s,deep:r,once:o,scheduler:i,augmentJob:a,call:l}=n,u=P=>r?P:Ze(P)||r===!1||r===0?Ot(P,1):Ot(P);let c,f,p,m,g=!1,b=!1;if(ve(e)?(f=()=>e.value,g=Ze(e)):Kt(e)?(f=()=>u(e),g=!0):U(e)?(b=!0,g=e.some(P=>Kt(P)||Ze(P)),f=()=>e.map(P=>{if(ve(P))return P.value;if(Kt(P))return u(P);if(Q(P))return l?l(P,2):P()})):Q(e)?t?f=l?()=>l(e,2):e:f=()=>{if(p){Dt();try{p()}finally{Lt()}}const P=Yt;Yt=c;try{return l?l(e,3,[m]):e(m)}finally{Yt=P}}:f=Et,t&&r){const P=f,V=r===!0?1/0:r;f=()=>Ot(P(),V)}const v=wa(),T=()=>{c.stop(),v&&v.active&&lo(v.effects,c)};if(o&&t){const P=t;t=(...V)=>{P(...V),T()}}let x=b?new Array(e.length).fill(gs):gs;const N=P=>{if(!(!(c.flags&1)||!c.dirty&&!P))if(t){const V=c.run();if(r||g||(b?V.some((z,H)=>qt(z,x[H])):qt(V,x))){p&&p();const z=Yt;Yt=c;try{const H=[V,x===gs?void 0:b&&x[0]===gs?[]:x,m];x=V,l?l(t,3,H):t(...H)}finally{Yt=z}}}else c.run()};return a&&a(N),c=new Aa(f),c.scheduler=i?()=>i(N,!1):N,m=P=>xu(P,!1,c),p=c.onStop=()=>{const P=Ls.get(c);if(P){if(l)l(P,4);else for(const V of P)V();Ls.delete(c)}},t?s?N(!0):x=c.run():i?i(N.bind(null,!0),!0):c.run(),T.pause=c.pause.bind(c),T.resume=c.resume.bind(c),T.stop=T,T}function Ot(e,t=1/0,n){if(t<=0||!fe(e)||e.__v_skip||(n=n||new Map,(n.get(e)||0)>=t))return e;if(n.set(e,t),t--,ve(e))Ot(e.value,t,n);else if(U(e))for(let s=0;s{Ot(s,t,n)});else if(ga(e)){for(const s in e)Ot(e[s],t,n);for(const s of Object.getOwnPropertySymbols(e))Object.prototype.propertyIsEnumerable.call(e,s)&&Ot(e[s],t,n)}return e}/** +* @vue/runtime-core v3.5.24 +* (c) 2018-present Yuxi (Evan) You and Vue contributors +* @license MIT +**/function is(e,t,n,s){try{return s?e(...s):e()}catch(r){Ys(r,t,n)}}function at(e,t,n,s){if(Q(e)){const r=is(e,t,n,s);return r&&pa(r)&&r.catch(o=>{Ys(o,t,n)}),r}if(U(e)){const r=[];for(let o=0;o>>1,r=Fe[s],o=Xn(r);o=Xn(n)?Fe.push(e):Fe.splice(Nu(t),0,e),e.flags|=1,Ha()}}function Ha(){Ms||(Ms=Ua.then(qa))}function Du(e){U(e)?vn.push(...e):Ut&&e.id===-1?Ut.splice(mn+1,0,e):e.flags&1||(vn.push(e),e.flags|=1),Ha()}function Ho(e,t,n=bt+1){for(;nXn(n)-Xn(s));if(vn.length=0,Ut){Ut.push(...t);return}for(Ut=t,mn=0;mne.id==null?e.flags&2?-1:1/0:e.id;function qa(e){try{for(bt=0;bt{s._d&&Vs(-1);const o=Bs(t);let i;try{i=e(...r)}finally{Bs(o),s._d&&Vs(1)}return i};return s._n=!0,s._c=!0,s._d=!0,s}function Lg(e,t){if(Oe===null)return e;const n=rr(Oe),s=e.dirs||(e.dirs=[]);for(let r=0;re.__isTeleport,Un=e=>e&&(e.disabled||e.disabled===""),jo=e=>e&&(e.defer||e.defer===""),qo=e=>typeof SVGElement<"u"&&e instanceof SVGElement,Ko=e=>typeof MathMLElement=="function"&&e instanceof MathMLElement,$r=(e,t)=>{const n=e&&e.to;return ge(n)?t?t(n):null:n},Ga={name:"Teleport",__isTeleport:!0,process(e,t,n,s,r,o,i,a,l,u){const{mc:c,pc:f,pbc:p,o:{insert:m,querySelector:g,createText:b,createComment:v}}=u,T=Un(t.props);let{shapeFlag:x,children:N,dynamicChildren:P}=t;if(e==null){const V=t.el=b(""),z=t.anchor=b("");m(V,n,s),m(z,n,s);const H=(O,K)=>{x&16&&c(N,O,K,r,o,i,a,l)},$=()=>{const O=t.target=$r(t.props,g),K=za(O,t,b,m);O&&(i!=="svg"&&qo(O)?i="svg":i!=="mathml"&&Ko(O)&&(i="mathml"),r&&r.isCE&&(r.ce._teleportTargets||(r.ce._teleportTargets=new Set)).add(O),T||(H(O,K),Ss(t,!1)))};T&&(H(n,z),Ss(t,!0)),jo(t.props)?(t.el.__isMounted=!1,Le(()=>{$(),delete t.el.__isMounted},o)):$()}else{if(jo(t.props)&&e.el.__isMounted===!1){Le(()=>{Ga.process(e,t,n,s,r,o,i,a,l,u)},o);return}t.el=e.el,t.targetStart=e.targetStart;const V=t.anchor=e.anchor,z=t.target=e.target,H=t.targetAnchor=e.targetAnchor,$=Un(e.props),O=$?n:z,K=$?V:H;if(i==="svg"||qo(z)?i="svg":(i==="mathml"||Ko(z))&&(i="mathml"),P?(p(e.dynamicChildren,P,O,r,o,i,a),Co(e,t,!0)):l||f(e,t,O,K,r,o,i,a,!1),T)$?t.props&&e.props&&t.props.to!==e.props.to&&(t.props.to=e.props.to):ys(t,n,V,u,1);else if((t.props&&t.props.to)!==(e.props&&e.props.to)){const ee=t.target=$r(t.props,g);ee&&ys(t,ee,null,u,0)}else $&&ys(t,z,H,u,1);Ss(t,T)}},remove(e,t,n,{um:s,o:{remove:r}},o){const{shapeFlag:i,children:a,anchor:l,targetStart:u,targetAnchor:c,target:f,props:p}=e;if(f&&(r(u),r(c)),o&&r(l),i&16){const m=o||!Un(p);for(let g=0;g{e.isMounted=!0}),el(()=>{e.isUnmounting=!0}),e}const Ye=[Function,Array],Fu={mode:String,appear:Boolean,persisted:Boolean,onBeforeEnter:Ye,onEnter:Ye,onAfterEnter:Ye,onEnterCancelled:Ye,onBeforeLeave:Ye,onLeave:Ye,onAfterLeave:Ye,onLeaveCancelled:Ye,onBeforeAppear:Ye,onAppear:Ye,onAfterAppear:Ye,onAppearCancelled:Ye};function ku(e,t){const{leavingVNodes:n}=e;let s=n.get(t.type);return s||(s=Object.create(null),n.set(t.type,s)),s}function Ur(e,t,n,s,r){const{appear:o,mode:i,persisted:a=!1,onBeforeEnter:l,onEnter:u,onAfterEnter:c,onEnterCancelled:f,onBeforeLeave:p,onLeave:m,onAfterLeave:g,onLeaveCancelled:b,onBeforeAppear:v,onAppear:T,onAfterAppear:x,onAppearCancelled:N}=t,P=String(e.key),V=ku(n,e),z=(O,K)=>{O&&at(O,s,9,K)},H=(O,K)=>{const ee=K[1];z(O,K),U(O)?O.every(L=>L.length<=1)&&ee():O.length<=1&&ee()},$={mode:i,persisted:a,beforeEnter(O){let K=l;if(!n.isMounted)if(o)K=v||l;else return;O[Zt]&&O[Zt](!0);const ee=V[P];ee&&gn(e,ee)&&ee.el[Zt]&&ee.el[Zt](),z(K,[O])},enter(O){let K=u,ee=c,L=f;if(!n.isMounted)if(o)K=T||u,ee=x||c,L=N||f;else return;let Z=!1;const ue=O[_s]=Ae=>{Z||(Z=!0,Ae?z(L,[O]):z(ee,[O]),$.delayedLeave&&$.delayedLeave(),O[_s]=void 0)};K?H(K,[O,ue]):ue()},leave(O,K){const ee=String(e.key);if(O[_s]&&O[_s](!0),n.isUnmounting)return K();z(p,[O]);let L=!1;const Z=O[Zt]=ue=>{L||(L=!0,K(),ue?z(b,[O]):z(g,[O]),O[Zt]=void 0,V[ee]===e&&delete V[ee])};V[ee]=e,m?H(m,[O,Z]):Z()},clone(O){return Ur(O,t,n,s)}};return $}function Qn(e,t){e.shapeFlag&6&&e.component?(e.transition=t,Qn(e.component.subTree,t)):e.shapeFlag&128?(e.ssContent.transition=t.clone(e.ssContent),e.ssFallback.transition=t.clone(e.ssFallback)):e.transition=t}function Ja(e,t=!1,n){let s=[],r=0;for(let o=0;o1)for(let o=0;oHn(g,t&&(U(t)?t[b]:t),n,s,r));return}if(En(s)&&!r){s.shapeFlag&512&&s.type.__asyncResolved&&s.component.subTree.component&&Hn(e,t,n,s.component.subTree);return}const o=s.shapeFlag&4?rr(s.component):s.el,i=r?null:o,{i:a,r:l}=e,u=t&&t.r,c=a.refs===de?a.refs={}:a.refs,f=a.setupState,p=oe(f),m=f===de?ha:g=>ae(p,g);if(u!=null&&u!==l){if(Wo(t),ge(u))c[u]=null,m(u)&&(f[u]=null);else if(ve(u)){u.value=null;const g=t;g.k&&(c[g.k]=null)}}if(Q(l))is(l,a,12,[i,c]);else{const g=ge(l),b=ve(l);if(g||b){const v=()=>{if(e.f){const T=g?m(l)?f[l]:c[l]:l.value;if(r)U(T)&&lo(T,o);else if(U(T))T.includes(o)||T.push(o);else if(g)c[l]=[o],m(l)&&(f[l]=c[l]);else{const x=[o];l.value=x,e.k&&(c[e.k]=x)}}else g?(c[l]=i,m(l)&&(f[l]=i)):b&&(l.value=i,e.k&&(c[e.k]=i))};if(i){const T=()=>{v(),Fs.delete(e)};T.id=-1,Fs.set(e,T),Le(T,n)}else Wo(e),v()}}}function Wo(e){const t=Fs.get(e);t&&(t.flags|=8,Fs.delete(e))}Js().requestIdleCallback;Js().cancelIdleCallback;const En=e=>!!e.type.__asyncLoader,Qa=e=>e.type.__isKeepAlive;function Vu(e,t){Ya(e,"a",t)}function $u(e,t){Ya(e,"da",t)}function Ya(e,t,n=Ie){const s=e.__wdc||(e.__wdc=()=>{let r=n;for(;r;){if(r.isDeactivated)return;r=r.parent}return e()});if(Zs(t,s,n),n){let r=n.parent;for(;r&&r.parent;)Qa(r.parent.vnode)&&Uu(s,t,n,r),r=r.parent}}function Uu(e,t,n,s){const r=Zs(t,e,s,!0);tl(()=>{lo(s[t],r)},n)}function Zs(e,t,n=Ie,s=!1){if(n){const r=n[e]||(n[e]=[]),o=t.__weh||(t.__weh=(...i)=>{Dt();const a=ls(n),l=at(t,n,e,i);return a(),Lt(),l});return s?r.unshift(o):r.push(o),o}}const Bt=e=>(t,n=Ie)=>{(!es||e==="sp")&&Zs(e,(...s)=>t(...s),n)},Hu=Bt("bm"),Eo=Bt("m"),ju=Bt("bu"),Za=Bt("u"),el=Bt("bum"),tl=Bt("um"),qu=Bt("sp"),Ku=Bt("rtg"),Wu=Bt("rtc");function Gu(e,t=Ie){Zs("ec",e,t)}const nl="components";function Pt(e,t){return rl(nl,e,!0,t)||e}const sl=Symbol.for("v-ndc");function So(e){return ge(e)?rl(nl,e,!1)||e:e||sl}function rl(e,t,n=!0,s=!1){const r=Oe||Ie;if(r){const o=r.type;{const a=Ff(o,!1);if(a&&(a===t||a===tt(t)||a===Gs(tt(t))))return o}const i=Go(r[e]||o[e],t)||Go(r.appContext[e],t);return!i&&s?o:i}}function Go(e,t){return e&&(e[t]||e[tt(t)]||e[Gs(tt(t))])}function zo(e,t,n,s){let r;const o=n,i=U(e);if(i||ge(e)){const a=i&&Kt(e);let l=!1,u=!1;a&&(l=!Ze(e),u=Wt(e),e=Xs(e)),r=new Array(e.length);for(let c=0,f=e.length;ct(a,l,void 0,o));else{const a=Object.keys(e);r=new Array(a.length);for(let l=0,u=a.length;l0;return ce(),ke(Te,null,[me("slot",n,s)],u?-2:64)}let o=e[t];o&&o._c&&(o._d=!1),ce();const i=o&&ol(o(n)),a=n.key||i&&i.key,l=ke(Te,{key:(a&&!it(a)?a:`_${t}`)+(!i&&s?"_fb":"")},i||[],i&&e._===1?64:-2);return l.scopeId&&(l.slotScopeIds=[l.scopeId+"-s"]),o&&o._c&&(o._d=!0),l}function ol(e){return e.some(t=>Zn(t)?!(t.type===wt||t.type===Te&&!ol(t.children)):!0)?e:null}function Ju(e,t){const n={};for(const s in e)n[vs(s)]=e[s];return n}const Hr=e=>e?Al(e)?rr(e):Hr(e.parent):null,jn=we(Object.create(null),{$:e=>e,$el:e=>e.vnode.el,$data:e=>e.data,$props:e=>e.props,$attrs:e=>e.attrs,$slots:e=>e.slots,$refs:e=>e.refs,$parent:e=>Hr(e.parent),$root:e=>Hr(e.root),$host:e=>e.ce,$emit:e=>e.emit,$options:e=>al(e),$forceUpdate:e=>e.f||(e.f=()=>{vo(e.update)}),$nextTick:e=>e.n||(e.n=as.bind(e.proxy)),$watch:e=>_f.bind(e)}),Er=(e,t)=>e!==de&&!e.__isScriptSetup&&ae(e,t),Xu={get({_:e},t){if(t==="__v_skip")return!0;const{ctx:n,setupState:s,data:r,props:o,accessCache:i,type:a,appContext:l}=e;let u;if(t[0]!=="$"){const m=i[t];if(m!==void 0)switch(m){case 1:return s[t];case 2:return r[t];case 4:return n[t];case 3:return o[t]}else{if(Er(s,t))return i[t]=1,s[t];if(r!==de&&ae(r,t))return i[t]=2,r[t];if((u=e.propsOptions[0])&&ae(u,t))return i[t]=3,o[t];if(n!==de&&ae(n,t))return i[t]=4,n[t];jr&&(i[t]=0)}}const c=jn[t];let f,p;if(c)return t==="$attrs"&&Pe(e.attrs,"get",""),c(e);if((f=a.__cssModules)&&(f=f[t]))return f;if(n!==de&&ae(n,t))return i[t]=4,n[t];if(p=l.config.globalProperties,ae(p,t))return p[t]},set({_:e},t,n){const{data:s,setupState:r,ctx:o}=e;return Er(r,t)?(r[t]=n,!0):s!==de&&ae(s,t)?(s[t]=n,!0):ae(e.props,t)||t[0]==="$"&&t.slice(1)in e?!1:(o[t]=n,!0)},has({_:{data:e,setupState:t,accessCache:n,ctx:s,appContext:r,propsOptions:o,type:i}},a){let l,u;return!!(n[a]||e!==de&&a[0]!=="$"&&ae(e,a)||Er(t,a)||(l=o[0])&&ae(l,a)||ae(s,a)||ae(jn,a)||ae(r.config.globalProperties,a)||(u=i.__cssModules)&&u[a])},defineProperty(e,t,n){return n.get!=null?e._.accessCache[t]=0:ae(n,"value")&&this.set(e,t,n.value,null),Reflect.defineProperty(e,t,n)}};function Jo(e){return U(e)?e.reduce((t,n)=>(t[n]=null,t),{}):e}let jr=!0;function Qu(e){const t=al(e),n=e.proxy,s=e.ctx;jr=!1,t.beforeCreate&&Xo(t.beforeCreate,e,"bc");const{data:r,computed:o,methods:i,watch:a,provide:l,inject:u,created:c,beforeMount:f,mounted:p,beforeUpdate:m,updated:g,activated:b,deactivated:v,beforeDestroy:T,beforeUnmount:x,destroyed:N,unmounted:P,render:V,renderTracked:z,renderTriggered:H,errorCaptured:$,serverPrefetch:O,expose:K,inheritAttrs:ee,components:L,directives:Z,filters:ue}=t;if(u&&Yu(u,s,null),i)for(const J in i){const te=i[J];Q(te)&&(s[J]=te.bind(n))}if(r){const J=r.call(n,n);fe(J)&&(e.data=os(J))}if(jr=!0,o)for(const J in o){const te=o[J],Xe=Q(te)?te.bind(n,n):Q(te.get)?te.get.bind(n,n):Et,ut=!Q(te)&&Q(te.set)?te.set.bind(n):Et,Ee=Re({get:Xe,set:ut});Object.defineProperty(s,J,{enumerable:!0,configurable:!0,get:()=>Ee.value,set:be=>Ee.value=be})}if(a)for(const J in a)il(a[J],s,n,J);if(l){const J=Q(l)?l.call(n):l;Reflect.ownKeys(J).forEach(te=>{ws(te,J[te])})}c&&Xo(c,e,"c");function ne(J,te){U(te)?te.forEach(Xe=>J(Xe.bind(n))):te&&J(te.bind(n))}if(ne(Hu,f),ne(Eo,p),ne(ju,m),ne(Za,g),ne(Vu,b),ne($u,v),ne(Gu,$),ne(Wu,z),ne(Ku,H),ne(el,x),ne(tl,P),ne(qu,O),U(K))if(K.length){const J=e.exposed||(e.exposed={});K.forEach(te=>{Object.defineProperty(J,te,{get:()=>n[te],set:Xe=>n[te]=Xe,enumerable:!0})})}else e.exposed||(e.exposed={});V&&e.render===Et&&(e.render=V),ee!=null&&(e.inheritAttrs=ee),L&&(e.components=L),Z&&(e.directives=Z),O&&Xa(e)}function Yu(e,t,n=Et){U(e)&&(e=qr(e));for(const s in e){const r=e[s];let o;fe(r)?"default"in r?o=Je(r.from||s,r.default,!0):o=Je(r.from||s):o=Je(r),ve(o)?Object.defineProperty(t,s,{enumerable:!0,configurable:!0,get:()=>o.value,set:i=>o.value=i}):t[s]=o}}function Xo(e,t,n){at(U(e)?e.map(s=>s.bind(t.proxy)):e.bind(t.proxy),t,n)}function il(e,t,n,s){let r=s.includes(".")?bl(n,s):()=>n[s];if(ge(e)){const o=t[e];Q(o)&&qn(r,o)}else if(Q(e))qn(r,e.bind(n));else if(fe(e))if(U(e))e.forEach(o=>il(o,t,n,s));else{const o=Q(e.handler)?e.handler.bind(n):t[e.handler];Q(o)&&qn(r,o,e)}}function al(e){const t=e.type,{mixins:n,extends:s}=t,{mixins:r,optionsCache:o,config:{optionMergeStrategies:i}}=e.appContext,a=o.get(t);let l;return a?l=a:!r.length&&!n&&!s?l=t:(l={},r.length&&r.forEach(u=>ks(l,u,i,!0)),ks(l,t,i)),fe(t)&&o.set(t,l),l}function ks(e,t,n,s=!1){const{mixins:r,extends:o}=t;o&&ks(e,o,n,!0),r&&r.forEach(i=>ks(e,i,n,!0));for(const i in t)if(!(s&&i==="expose")){const a=Zu[i]||n&&n[i];e[i]=a?a(e[i],t[i]):t[i]}return e}const Zu={data:Qo,props:Yo,emits:Yo,methods:Fn,computed:Fn,beforeCreate:De,created:De,beforeMount:De,mounted:De,beforeUpdate:De,updated:De,beforeDestroy:De,beforeUnmount:De,destroyed:De,unmounted:De,activated:De,deactivated:De,errorCaptured:De,serverPrefetch:De,components:Fn,directives:Fn,watch:tf,provide:Qo,inject:ef};function Qo(e,t){return t?e?function(){return we(Q(e)?e.call(this,this):e,Q(t)?t.call(this,this):t)}:t:e}function ef(e,t){return Fn(qr(e),qr(t))}function qr(e){if(U(e)){const t={};for(let n=0;n1)return n&&Q(t)?t.call(s&&s.proxy):t}}function rf(){return!!(sr()||an)}const cl={},ul=()=>Object.create(cl),fl=e=>Object.getPrototypeOf(e)===cl;function of(e,t,n,s=!1){const r={},o=ul();e.propsDefaults=Object.create(null),dl(e,t,r,o);for(const i in e.propsOptions[0])i in r||(r[i]=void 0);n?e.props=s?r:ka(r):e.type.props?e.props=r:e.props=o,e.attrs=o}function af(e,t,n,s){const{props:r,attrs:o,vnode:{patchFlag:i}}=e,a=oe(r),[l]=e.propsOptions;let u=!1;if((s||i>0)&&!(i&16)){if(i&8){const c=e.vnode.dynamicProps;for(let f=0;f{l=!0;const[p,m]=hl(f,t,!0);we(i,p),m&&a.push(...m)};!n&&t.mixins.length&&t.mixins.forEach(c),e.extends&&c(e.extends),e.mixins&&e.mixins.forEach(c)}if(!o&&!l)return fe(e)&&s.set(e,_n),_n;if(U(o))for(let c=0;ce==="_"||e==="_ctx"||e==="$stable",Ao=e=>U(e)?e.map(vt):[vt(e)],cf=(e,t,n)=>{if(t._n)return t;const s=Me((...r)=>Ao(t(...r)),n);return s._c=!1,s},pl=(e,t,n)=>{const s=e._ctx;for(const r in e){if(wo(r))continue;const o=e[r];if(Q(o))t[r]=cf(r,o,s);else if(o!=null){const i=Ao(o);t[r]=()=>i}}},ml=(e,t)=>{const n=Ao(t);e.slots.default=()=>n},gl=(e,t,n)=>{for(const s in t)(n||!wo(s))&&(e[s]=t[s])},uf=(e,t,n)=>{const s=e.slots=ul();if(e.vnode.shapeFlag&32){const r=t._;r?(gl(s,t,n),n&&ya(s,"_",r,!0)):pl(t,s)}else t&&ml(e,t)},ff=(e,t,n)=>{const{vnode:s,slots:r}=e;let o=!0,i=de;if(s.shapeFlag&32){const a=t._;a?n&&a===1?o=!1:gl(r,t,n):(o=!t.$stable,pl(t,r)),i=t}else t&&(ml(e,t),i={default:1});if(o)for(const a in r)!wo(a)&&i[a]==null&&delete r[a]},Le=Tf;function df(e){return hf(e)}function hf(e,t){const n=Js();n.__VUE__=!0;const{insert:s,remove:r,patchProp:o,createElement:i,createText:a,createComment:l,setText:u,setElementText:c,parentNode:f,nextSibling:p,setScopeId:m=Et,insertStaticContent:g}=e,b=(d,h,y,S=null,A=null,E=null,M=void 0,D=null,I=!!h.dynamicChildren)=>{if(d===h)return;d&&!gn(d,h)&&(S=w(d),be(d,A,E,!0),d=null),h.patchFlag===-2&&(I=!1,h.dynamicChildren=null);const{type:C,ref:W,shapeFlag:F}=h;switch(C){case tr:v(d,h,y,S);break;case wt:T(d,h,y,S);break;case As:d==null&&x(h,y,S,M);break;case Te:L(d,h,y,S,A,E,M,D,I);break;default:F&1?V(d,h,y,S,A,E,M,D,I):F&6?Z(d,h,y,S,A,E,M,D,I):(F&64||F&128)&&C.process(d,h,y,S,A,E,M,D,I,j)}W!=null&&A?Hn(W,d&&d.ref,E,h||d,!h):W==null&&d&&d.ref!=null&&Hn(d.ref,null,E,d,!0)},v=(d,h,y,S)=>{if(d==null)s(h.el=a(h.children),y,S);else{const A=h.el=d.el;h.children!==d.children&&u(A,h.children)}},T=(d,h,y,S)=>{d==null?s(h.el=l(h.children||""),y,S):h.el=d.el},x=(d,h,y,S)=>{[d.el,d.anchor]=g(d.children,h,y,S,d.el,d.anchor)},N=({el:d,anchor:h},y,S)=>{let A;for(;d&&d!==h;)A=p(d),s(d,y,S),d=A;s(h,y,S)},P=({el:d,anchor:h})=>{let y;for(;d&&d!==h;)y=p(d),r(d),d=y;r(h)},V=(d,h,y,S,A,E,M,D,I)=>{if(h.type==="svg"?M="svg":h.type==="math"&&(M="mathml"),d==null)z(h,y,S,A,E,M,D,I);else{const C=d.el&&d.el._isVueCE?d.el:null;try{C&&C._beginPatch(),O(d,h,A,E,M,D,I)}finally{C&&C._endPatch()}}},z=(d,h,y,S,A,E,M,D)=>{let I,C;const{props:W,shapeFlag:F,transition:q,dirs:G}=d;if(I=d.el=i(d.type,E,W&&W.is,W),F&8?c(I,d.children):F&16&&$(d.children,I,null,S,A,Sr(d,E),M,D),G&&Jt(d,null,S,"created"),H(I,d,d.scopeId,M,S),W){for(const he in W)he!=="value"&&!kn(he)&&o(I,he,null,W[he],E,S);"value"in W&&o(I,"value",null,W.value,E),(C=W.onVnodeBeforeMount)&>(C,S,d)}G&&Jt(d,null,S,"beforeMount");const re=pf(A,q);re&&q.beforeEnter(I),s(I,h,y),((C=W&&W.onVnodeMounted)||re||G)&&Le(()=>{C&>(C,S,d),re&&q.enter(I),G&&Jt(d,null,S,"mounted")},A)},H=(d,h,y,S,A)=>{if(y&&m(d,y),S)for(let E=0;E{for(let C=I;C{const D=h.el=d.el;let{patchFlag:I,dynamicChildren:C,dirs:W}=h;I|=d.patchFlag&16;const F=d.props||de,q=h.props||de;let G;if(y&&Xt(y,!1),(G=q.onVnodeBeforeUpdate)&>(G,y,h,d),W&&Jt(h,d,y,"beforeUpdate"),y&&Xt(y,!0),(F.innerHTML&&q.innerHTML==null||F.textContent&&q.textContent==null)&&c(D,""),C?K(d.dynamicChildren,C,D,y,S,Sr(h,A),E):M||te(d,h,D,null,y,S,Sr(h,A),E,!1),I>0){if(I&16)ee(D,F,q,y,A);else if(I&2&&F.class!==q.class&&o(D,"class",null,q.class,A),I&4&&o(D,"style",F.style,q.style,A),I&8){const re=h.dynamicProps;for(let he=0;he{G&>(G,y,h,d),W&&Jt(h,d,y,"updated")},S)},K=(d,h,y,S,A,E,M)=>{for(let D=0;D{if(h!==y){if(h!==de)for(const E in h)!kn(E)&&!(E in y)&&o(d,E,h[E],null,A,S);for(const E in y){if(kn(E))continue;const M=y[E],D=h[E];M!==D&&E!=="value"&&o(d,E,D,M,A,S)}"value"in y&&o(d,"value",h.value,y.value,A)}},L=(d,h,y,S,A,E,M,D,I)=>{const C=h.el=d?d.el:a(""),W=h.anchor=d?d.anchor:a("");let{patchFlag:F,dynamicChildren:q,slotScopeIds:G}=h;G&&(D=D?D.concat(G):G),d==null?(s(C,y,S),s(W,y,S),$(h.children||[],y,W,A,E,M,D,I)):F>0&&F&64&&q&&d.dynamicChildren?(K(d.dynamicChildren,q,y,A,E,M,D),(h.key!=null||A&&h===A.subTree)&&Co(d,h,!0)):te(d,h,y,W,A,E,M,D,I)},Z=(d,h,y,S,A,E,M,D,I)=>{h.slotScopeIds=D,d==null?h.shapeFlag&512?A.ctx.activate(h,y,S,M,I):ue(h,y,S,A,E,M,I):Ae(d,h,I)},ue=(d,h,y,S,A,E,M)=>{const D=d.component=Nf(d,S,A);if(Qa(d)&&(D.ctx.renderer=j),Df(D,!1,M),D.asyncDep){if(A&&A.registerDep(D,ne,M),!d.el){const I=D.subTree=me(wt);T(null,I,h,y),d.placeholder=I.el}}else ne(D,d,h,y,A,E,M)},Ae=(d,h,y)=>{const S=h.component=d.component;if(Af(d,h,y))if(S.asyncDep&&!S.asyncResolved){J(S,h,y);return}else S.next=h,S.update();else h.el=d.el,S.vnode=h},ne=(d,h,y,S,A,E,M)=>{const D=()=>{if(d.isMounted){let{next:F,bu:q,u:G,parent:re,vnode:he}=d;{const pt=yl(d);if(pt){F&&(F.el=he.el,J(d,F,M)),pt.asyncDep.then(()=>{d.isUnmounted||D()});return}}let le=F,Ve;Xt(d,!1),F?(F.el=he.el,J(d,F,M)):F=he,q&&Es(q),(Ve=F.props&&F.props.onVnodeBeforeUpdate)&>(Ve,re,F,he),Xt(d,!0);const $e=ti(d),ht=d.subTree;d.subTree=$e,b(ht,$e,f(ht.el),w(ht),d,A,E),F.el=$e.el,le===null&&Cf(d,$e.el),G&&Le(G,A),(Ve=F.props&&F.props.onVnodeUpdated)&&Le(()=>gt(Ve,re,F,he),A)}else{let F;const{el:q,props:G}=h,{bm:re,m:he,parent:le,root:Ve,type:$e}=d,ht=En(h);Xt(d,!1),re&&Es(re),!ht&&(F=G&&G.onVnodeBeforeMount)&>(F,le,h),Xt(d,!0);{Ve.ce&&Ve.ce._def.shadowRoot!==!1&&Ve.ce._injectChildStyle($e);const pt=d.subTree=ti(d);b(null,pt,y,S,d,A,E),h.el=pt.el}if(he&&Le(he,A),!ht&&(F=G&&G.onVnodeMounted)){const pt=h;Le(()=>gt(F,le,pt),A)}(h.shapeFlag&256||le&&En(le.vnode)&&le.vnode.shapeFlag&256)&&d.a&&Le(d.a,A),d.isMounted=!0,h=y=S=null}};d.scope.on();const I=d.effect=new Aa(D);d.scope.off();const C=d.update=I.run.bind(I),W=d.job=I.runIfDirty.bind(I);W.i=d,W.id=d.uid,I.scheduler=()=>vo(W),Xt(d,!0),C()},J=(d,h,y)=>{h.component=d;const S=d.vnode.props;d.vnode=h,d.next=null,af(d,h.props,S,y),ff(d,h.children,y),Dt(),Ho(d),Lt()},te=(d,h,y,S,A,E,M,D,I=!1)=>{const C=d&&d.children,W=d?d.shapeFlag:0,F=h.children,{patchFlag:q,shapeFlag:G}=h;if(q>0){if(q&128){ut(C,F,y,S,A,E,M,D,I);return}else if(q&256){Xe(C,F,y,S,A,E,M,D,I);return}}G&8?(W&16&&Qe(C,A,E),F!==C&&c(y,F)):W&16?G&16?ut(C,F,y,S,A,E,M,D,I):Qe(C,A,E,!0):(W&8&&c(y,""),G&16&&$(F,y,S,A,E,M,D,I))},Xe=(d,h,y,S,A,E,M,D,I)=>{d=d||_n,h=h||_n;const C=d.length,W=h.length,F=Math.min(C,W);let q;for(q=0;qW?Qe(d,A,E,!0,!1,F):$(h,y,S,A,E,M,D,I,F)},ut=(d,h,y,S,A,E,M,D,I)=>{let C=0;const W=h.length;let F=d.length-1,q=W-1;for(;C<=F&&C<=q;){const G=d[C],re=h[C]=I?Ht(h[C]):vt(h[C]);if(gn(G,re))b(G,re,y,null,A,E,M,D,I);else break;C++}for(;C<=F&&C<=q;){const G=d[F],re=h[q]=I?Ht(h[q]):vt(h[q]);if(gn(G,re))b(G,re,y,null,A,E,M,D,I);else break;F--,q--}if(C>F){if(C<=q){const G=q+1,re=Gq)for(;C<=F;)be(d[C],A,E,!0),C++;else{const G=C,re=C,he=new Map;for(C=re;C<=q;C++){const Ke=h[C]=I?Ht(h[C]):vt(h[C]);Ke.key!=null&&he.set(Ke.key,C)}let le,Ve=0;const $e=q-re+1;let ht=!1,pt=0;const In=new Array($e);for(C=0;C<$e;C++)In[C]=0;for(C=G;C<=F;C++){const Ke=d[C];if(Ve>=$e){be(Ke,A,E,!0);continue}let mt;if(Ke.key!=null)mt=he.get(Ke.key);else for(le=re;le<=q;le++)if(In[le-re]===0&&gn(Ke,h[le])){mt=le;break}mt===void 0?be(Ke,A,E,!0):(In[mt-re]=C+1,mt>=pt?pt=mt:ht=!0,b(Ke,h[mt],y,null,A,E,M,D,I),Ve++)}const Mo=ht?mf(In):_n;for(le=Mo.length-1,C=$e-1;C>=0;C--){const Ke=re+C,mt=h[Ke],Bo=h[Ke+1],Fo=Ke+1{const{el:E,type:M,transition:D,children:I,shapeFlag:C}=d;if(C&6){Ee(d.component.subTree,h,y,S);return}if(C&128){d.suspense.move(h,y,S);return}if(C&64){M.move(d,h,y,j);return}if(M===Te){s(E,h,y);for(let F=0;FD.enter(E),A);else{const{leave:F,delayLeave:q,afterLeave:G}=D,re=()=>{d.ctx.isUnmounted?r(E):s(E,h,y)},he=()=>{E._isLeaving&&E[Zt](!0),F(E,()=>{re(),G&&G()})};q?q(E,re,he):he()}else s(E,h,y)},be=(d,h,y,S=!1,A=!1)=>{const{type:E,props:M,ref:D,children:I,dynamicChildren:C,shapeFlag:W,patchFlag:F,dirs:q,cacheIndex:G}=d;if(F===-2&&(A=!1),D!=null&&(Dt(),Hn(D,null,y,d,!0),Lt()),G!=null&&(h.renderCache[G]=void 0),W&256){h.ctx.deactivate(d);return}const re=W&1&&q,he=!En(d);let le;if(he&&(le=M&&M.onVnodeBeforeUnmount)&>(le,h,d),W&6)dt(d.component,y,S);else{if(W&128){d.suspense.unmount(y,S);return}re&&Jt(d,null,h,"beforeUnmount"),W&64?d.type.remove(d,h,y,j,S):C&&!C.hasOnce&&(E!==Te||F>0&&F&64)?Qe(C,h,y,!1,!0):(E===Te&&F&384||!A&&W&16)&&Qe(I,h,y),S&&ft(d)}(he&&(le=M&&M.onVnodeUnmounted)||re)&&Le(()=>{le&>(le,h,d),re&&Jt(d,null,h,"unmounted")},y)},ft=d=>{const{type:h,el:y,anchor:S,transition:A}=d;if(h===Te){nt(y,S);return}if(h===As){P(d);return}const E=()=>{r(y),A&&!A.persisted&&A.afterLeave&&A.afterLeave()};if(d.shapeFlag&1&&A&&!A.persisted){const{leave:M,delayLeave:D}=A,I=()=>M(y,E);D?D(d.el,E,I):I()}else E()},nt=(d,h)=>{let y;for(;d!==h;)y=p(d),r(d),d=y;r(h)},dt=(d,h,y)=>{const{bum:S,scope:A,job:E,subTree:M,um:D,m:I,a:C}=d;ei(I),ei(C),S&&Es(S),A.stop(),E&&(E.flags|=8,be(M,d,h,y)),D&&Le(D,h),Le(()=>{d.isUnmounted=!0},h)},Qe=(d,h,y,S=!1,A=!1,E=0)=>{for(let M=E;M{if(d.shapeFlag&6)return w(d.component.subTree);if(d.shapeFlag&128)return d.suspense.next();const h=p(d.anchor||d.el),y=h&&h[Wa];return y?p(y):h};let k=!1;const B=(d,h,y)=>{d==null?h._vnode&&be(h._vnode,null,null,!0):b(h._vnode||null,d,h,null,null,null,y),h._vnode=d,k||(k=!0,Ho(),ja(),k=!1)},j={p:b,um:be,m:Ee,r:ft,mt:ue,mc:$,pc:te,pbc:K,n:w,o:e};return{render:B,hydrate:void 0,createApp:sf(B)}}function Sr({type:e,props:t},n){return n==="svg"&&e==="foreignObject"||n==="mathml"&&e==="annotation-xml"&&t&&t.encoding&&t.encoding.includes("html")?void 0:n}function Xt({effect:e,job:t},n){n?(e.flags|=32,t.flags|=4):(e.flags&=-33,t.flags&=-5)}function pf(e,t){return(!e||e&&!e.pendingBranch)&&t&&!t.persisted}function Co(e,t,n=!1){const s=e.children,r=t.children;if(U(s)&&U(r))for(let o=0;o>1,e[n[a]]0&&(t[s]=n[o-1]),n[o]=s)}}for(o=n.length,i=n[o-1];o-- >0;)n[o]=i,i=t[i];return n}function yl(e){const t=e.subTree.component;if(t)return t.asyncDep&&!t.asyncResolved?t:yl(t)}function ei(e){if(e)for(let t=0;tJe(gf);function qn(e,t,n){return _l(e,t,n)}function _l(e,t,n=de){const{immediate:s,deep:r,flush:o,once:i}=n,a=we({},n),l=t&&s||!t&&o!=="post";let u;if(es){if(o==="sync"){const m=yf();u=m.__watcherHandles||(m.__watcherHandles=[])}else if(!l){const m=()=>{};return m.stop=Et,m.resume=Et,m.pause=Et,m}}const c=Ie;a.call=(m,g,b)=>at(m,c,g,b);let f=!1;o==="post"?a.scheduler=m=>{Le(m,c&&c.suspense)}:o!=="sync"&&(f=!0,a.scheduler=(m,g)=>{g?m():vo(m)}),a.augmentJob=m=>{t&&(m.flags|=4),f&&(m.flags|=2,c&&(m.id=c.uid,m.i=c))};const p=Pu(e,t,a);return es&&(u?u.push(p):l&&p()),p}function _f(e,t,n){const s=this.proxy,r=ge(e)?e.includes(".")?bl(s,e):()=>s[e]:e.bind(s,s);let o;Q(t)?o=t:(o=t.handler,n=t);const i=ls(this),a=_l(r,o.bind(s),n);return i(),a}function bl(e,t){const n=t.split(".");return()=>{let s=e;for(let r=0;rt==="modelValue"||t==="model-value"?e.modelModifiers:e[`${t}Modifiers`]||e[`${tt(t)}Modifiers`]||e[`${zt(t)}Modifiers`];function vf(e,t,...n){if(e.isUnmounted)return;const s=e.vnode.props||de;let r=n;const o=t.startsWith("update:"),i=o&&bf(s,t.slice(7));i&&(i.trim&&(r=n.map(c=>ge(c)?c.trim():c)),i.number&&(r=n.map(zs)));let a,l=s[a=vs(t)]||s[a=vs(tt(t))];!l&&o&&(l=s[a=vs(zt(t))]),l&&at(l,e,6,r);const u=s[a+"Once"];if(u){if(!e.emitted)e.emitted={};else if(e.emitted[a])return;e.emitted[a]=!0,at(u,e,6,r)}}const Ef=new WeakMap;function vl(e,t,n=!1){const s=n?Ef:t.emitsCache,r=s.get(e);if(r!==void 0)return r;const o=e.emits;let i={},a=!1;if(!Q(e)){const l=u=>{const c=vl(u,t,!0);c&&(a=!0,we(i,c))};!n&&t.mixins.length&&t.mixins.forEach(l),e.extends&&l(e.extends),e.mixins&&e.mixins.forEach(l)}return!o&&!a?(fe(e)&&s.set(e,null),null):(U(o)?o.forEach(l=>i[l]=null):we(i,o),fe(e)&&s.set(e,i),i)}function er(e,t){return!e||!Ks(t)?!1:(t=t.slice(2).replace(/Once$/,""),ae(e,t[0].toLowerCase()+t.slice(1))||ae(e,zt(t))||ae(e,t))}function ti(e){const{type:t,vnode:n,proxy:s,withProxy:r,propsOptions:[o],slots:i,attrs:a,emit:l,render:u,renderCache:c,props:f,data:p,setupState:m,ctx:g,inheritAttrs:b}=e,v=Bs(e);let T,x;try{if(n.shapeFlag&4){const P=r||s,V=P;T=vt(u.call(V,P,c,f,m,p,g)),x=a}else{const P=t;T=vt(P.length>1?P(f,{attrs:a,slots:i,emit:l}):P(f,null)),x=t.props?a:Sf(a)}}catch(P){Kn.length=0,Ys(P,e,1),T=me(wt)}let N=T;if(x&&b!==!1){const P=Object.keys(x),{shapeFlag:V}=N;P.length&&V&7&&(o&&P.some(ao)&&(x=wf(x,o)),N=fn(N,x,!1,!0))}return n.dirs&&(N=fn(N,null,!1,!0),N.dirs=N.dirs?N.dirs.concat(n.dirs):n.dirs),n.transition&&Qn(N,n.transition),T=N,Bs(v),T}const Sf=e=>{let t;for(const n in e)(n==="class"||n==="style"||Ks(n))&&((t||(t={}))[n]=e[n]);return t},wf=(e,t)=>{const n={};for(const s in e)(!ao(s)||!(s.slice(9)in t))&&(n[s]=e[s]);return n};function Af(e,t,n){const{props:s,children:r,component:o}=e,{props:i,children:a,patchFlag:l}=t,u=o.emitsOptions;if(t.dirs||t.transition)return!0;if(n&&l>=0){if(l&1024)return!0;if(l&16)return s?ni(s,i,u):!!i;if(l&8){const c=t.dynamicProps;for(let f=0;fe.__isSuspense;function Tf(e,t){t&&t.pendingBranch?U(e)?t.effects.push(...e):t.effects.push(e):Du(e)}const Te=Symbol.for("v-fgt"),tr=Symbol.for("v-txt"),wt=Symbol.for("v-cmt"),As=Symbol.for("v-stc"),Kn=[];let ze=null;function ce(e=!1){Kn.push(ze=e?null:[])}function Rf(){Kn.pop(),ze=Kn[Kn.length-1]||null}let Yn=1;function Vs(e,t=!1){Yn+=e,e<0&&ze&&t&&(ze.hasOnce=!0)}function Sl(e){return e.dynamicChildren=Yn>0?ze||_n:null,Rf(),Yn>0&&ze&&ze.push(e),e}function He(e,t,n,s,r,o){return Sl(X(e,t,n,s,r,o,!0))}function ke(e,t,n,s,r){return Sl(me(e,t,n,s,r,!0))}function Zn(e){return e?e.__v_isVNode===!0:!1}function gn(e,t){return e.type===t.type&&e.key===t.key}const wl=({key:e})=>e??null,Cs=({ref:e,ref_key:t,ref_for:n})=>(typeof e=="number"&&(e=""+e),e!=null?ge(e)||ve(e)||Q(e)?{i:Oe,r:e,k:t,f:!!n}:e:null);function X(e,t=null,n=null,s=0,r=null,o=e===Te?0:1,i=!1,a=!1){const l={__v_isVNode:!0,__v_skip:!0,type:e,props:t,key:t&&wl(t),ref:t&&Cs(t),scopeId:Ka,slotScopeIds:null,children:n,component:null,suspense:null,ssContent:null,ssFallback:null,dirs:null,transition:null,el:null,anchor:null,target:null,targetStart:null,targetAnchor:null,staticCount:0,shapeFlag:o,patchFlag:s,dynamicProps:r,dynamicChildren:null,appContext:null,ctx:Oe};return a?(To(l,n),o&128&&e.normalize(l)):n&&(l.shapeFlag|=ge(n)?8:16),Yn>0&&!i&&ze&&(l.patchFlag>0||o&6)&&l.patchFlag!==32&&ze.push(l),l}const me=Of;function Of(e,t=null,n=null,s=0,r=null,o=!1){if((!e||e===sl)&&(e=wt),Zn(e)){const a=fn(e,t,!0);return n&&To(a,n),Yn>0&&!o&&ze&&(a.shapeFlag&6?ze[ze.indexOf(e)]=a:ze.push(a)),a.patchFlag=-2,a}if(kf(e)&&(e=e.__vccOpts),t){t=xf(t);let{class:a,style:l}=t;a&&!ge(a)&&(t.class=Nt(a)),fe(l)&&(_o(l)&&!U(l)&&(l=we({},l)),t.style=rs(l))}const i=ge(e)?1:El(e)?128:Lu(e)?64:fe(e)?4:Q(e)?2:0;return X(e,t,n,s,r,i,o,!0)}function xf(e){return e?_o(e)||fl(e)?we({},e):e:null}function fn(e,t,n=!1,s=!1){const{props:r,ref:o,patchFlag:i,children:a,transition:l}=e,u=t?nr(r||{},t):r,c={__v_isVNode:!0,__v_skip:!0,type:e.type,props:u,key:u&&wl(u),ref:t&&t.ref?n&&o?U(o)?o.concat(Cs(t)):[o,Cs(t)]:Cs(t):o,scopeId:e.scopeId,slotScopeIds:e.slotScopeIds,children:a,target:e.target,targetStart:e.targetStart,targetAnchor:e.targetAnchor,staticCount:e.staticCount,shapeFlag:e.shapeFlag,patchFlag:t&&e.type!==Te?i===-1?16:i|16:i,dynamicProps:e.dynamicProps,dynamicChildren:e.dynamicChildren,appContext:e.appContext,dirs:e.dirs,transition:l,component:e.component,suspense:e.suspense,ssContent:e.ssContent&&fn(e.ssContent),ssFallback:e.ssFallback&&fn(e.ssFallback),placeholder:e.placeholder,el:e.el,anchor:e.anchor,ctx:e.ctx,ce:e.ce};return l&&s&&Qn(c,l.clone(c)),c}function Sn(e=" ",t=0){return me(tr,null,e,t)}function Bg(e,t){const n=me(As,null,e);return n.staticCount=t,n}function ln(e="",t=!1){return t?(ce(),ke(wt,null,e)):me(wt,null,e)}function vt(e){return e==null||typeof e=="boolean"?me(wt):U(e)?me(Te,null,e.slice()):Zn(e)?Ht(e):me(tr,null,String(e))}function Ht(e){return e.el===null&&e.patchFlag!==-1||e.memo?e:fn(e)}function To(e,t){let n=0;const{shapeFlag:s}=e;if(t==null)t=null;else if(U(t))n=16;else if(typeof t=="object")if(s&65){const r=t.default;r&&(r._c&&(r._d=!1),To(e,r()),r._c&&(r._d=!0));return}else{n=32;const r=t._;!r&&!fl(t)?t._ctx=Oe:r===3&&Oe&&(Oe.slots._===1?t._=1:(t._=2,e.patchFlag|=1024))}else Q(t)?(t={default:t,_ctx:Oe},n=32):(t=String(t),s&64?(n=16,t=[Sn(t)]):n=8);e.children=t,e.shapeFlag|=n}function nr(...e){const t={};for(let n=0;nIe||Oe;let $s,Wr;{const e=Js(),t=(n,s)=>{let r;return(r=e[n])||(r=e[n]=[]),r.push(s),o=>{r.length>1?r.forEach(i=>i(o)):r[0](o)}};$s=t("__VUE_INSTANCE_SETTERS__",n=>Ie=n),Wr=t("__VUE_SSR_SETTERS__",n=>es=n)}const ls=e=>{const t=Ie;return $s(e),e.scope.on(),()=>{e.scope.off(),$s(t)}},si=()=>{Ie&&Ie.scope.off(),$s(null)};function Al(e){return e.vnode.shapeFlag&4}let es=!1;function Df(e,t=!1,n=!1){t&&Wr(t);const{props:s,children:r}=e.vnode,o=Al(e);of(e,s,o,t),uf(e,r,n||t);const i=o?Lf(e,t):void 0;return t&&Wr(!1),i}function Lf(e,t){const n=e.type;e.accessCache=Object.create(null),e.proxy=new Proxy(e.ctx,Xu);const{setup:s}=n;if(s){Dt();const r=e.setupContext=s.length>1?Bf(e):null,o=ls(e),i=is(s,e,0,[e.props,r]),a=pa(i);if(Lt(),o(),(a||e.sp)&&!En(e)&&Xa(e),a){if(i.then(si,si),t)return i.then(l=>{ri(e,l)}).catch(l=>{Ys(l,e,0)});e.asyncDep=i}else ri(e,i)}else Cl(e)}function ri(e,t,n){Q(t)?e.type.__ssrInlineRender?e.ssrRender=t:e.render=t:fe(t)&&(e.setupState=$a(t)),Cl(e)}function Cl(e,t,n){const s=e.type;e.render||(e.render=s.render||Et);{const r=ls(e);Dt();try{Qu(e)}finally{Lt(),r()}}}const Mf={get(e,t){return Pe(e,"get",""),e[t]}};function Bf(e){const t=n=>{e.exposed=n||{}};return{attrs:new Proxy(e.attrs,Mf),slots:e.slots,emit:e.emit,expose:t}}function rr(e){return e.exposed?e.exposeProxy||(e.exposeProxy=new Proxy($a(bo(e.exposed)),{get(t,n){if(n in t)return t[n];if(n in jn)return jn[n](e)},has(t,n){return n in t||n in jn}})):e.proxy}function Ff(e,t=!0){return Q(e)?e.displayName||e.name:e.name||t&&e.__name}function kf(e){return Q(e)&&"__vccOpts"in e}const Re=(e,t)=>Ou(e,t,es);function Tl(e,t,n){try{Vs(-1);const s=arguments.length;return s===2?fe(t)&&!U(t)?Zn(t)?me(e,null,[t]):me(e,t):me(e,null,t):(s>3?n=Array.prototype.slice.call(arguments,2):s===3&&Zn(n)&&(n=[n]),me(e,t,n))}finally{Vs(1)}}const Vf="3.5.24";/** +* @vue/runtime-dom v3.5.24 +* (c) 2018-present Yuxi (Evan) You and Vue contributors +* @license MIT +**/let Gr;const oi=typeof window<"u"&&window.trustedTypes;if(oi)try{Gr=oi.createPolicy("vue",{createHTML:e=>e})}catch{}const Rl=Gr?e=>Gr.createHTML(e):e=>e,$f="http://www.w3.org/2000/svg",Uf="http://www.w3.org/1998/Math/MathML",Tt=typeof document<"u"?document:null,ii=Tt&&Tt.createElement("template"),Hf={insert:(e,t,n)=>{t.insertBefore(e,n||null)},remove:e=>{const t=e.parentNode;t&&t.removeChild(e)},createElement:(e,t,n,s)=>{const r=t==="svg"?Tt.createElementNS($f,e):t==="mathml"?Tt.createElementNS(Uf,e):n?Tt.createElement(e,{is:n}):Tt.createElement(e);return e==="select"&&s&&s.multiple!=null&&r.setAttribute("multiple",s.multiple),r},createText:e=>Tt.createTextNode(e),createComment:e=>Tt.createComment(e),setText:(e,t)=>{e.nodeValue=t},setElementText:(e,t)=>{e.textContent=t},parentNode:e=>e.parentNode,nextSibling:e=>e.nextSibling,querySelector:e=>Tt.querySelector(e),setScopeId(e,t){e.setAttribute(t,"")},insertStaticContent(e,t,n,s,r,o){const i=n?n.previousSibling:t.lastChild;if(r&&(r===o||r.nextSibling))for(;t.insertBefore(r.cloneNode(!0),n),!(r===o||!(r=r.nextSibling)););else{ii.innerHTML=Rl(s==="svg"?`${e}`:s==="mathml"?`${e}`:e);const a=ii.content;if(s==="svg"||s==="mathml"){const l=a.firstChild;for(;l.firstChild;)a.appendChild(l.firstChild);a.removeChild(l)}t.insertBefore(a,n)}return[i?i.nextSibling:t.firstChild,n?n.previousSibling:t.lastChild]}},Ft="transition",Dn="animation",wn=Symbol("_vtc"),Ol={name:String,type:String,css:{type:Boolean,default:!0},duration:[String,Number,Object],enterFromClass:String,enterActiveClass:String,enterToClass:String,appearFromClass:String,appearActiveClass:String,appearToClass:String,leaveFromClass:String,leaveActiveClass:String,leaveToClass:String},jf=we({},Fu,Ol),Qt=(e,t=[])=>{U(e)?e.forEach(n=>n(...t)):e&&e(...t)},ai=e=>e?U(e)?e.some(t=>t.length>1):e.length>1:!1;function qf(e){const t={};for(const L in e)L in Ol||(t[L]=e[L]);if(e.css===!1)return t;const{name:n="v",type:s,duration:r,enterFromClass:o=`${n}-enter-from`,enterActiveClass:i=`${n}-enter-active`,enterToClass:a=`${n}-enter-to`,appearFromClass:l=o,appearActiveClass:u=i,appearToClass:c=a,leaveFromClass:f=`${n}-leave-from`,leaveActiveClass:p=`${n}-leave-active`,leaveToClass:m=`${n}-leave-to`}=e,g=Kf(r),b=g&&g[0],v=g&&g[1],{onBeforeEnter:T,onEnter:x,onEnterCancelled:N,onLeave:P,onLeaveCancelled:V,onBeforeAppear:z=T,onAppear:H=x,onAppearCancelled:$=N}=t,O=(L,Z,ue,Ae)=>{L._enterCancelled=Ae,Vt(L,Z?c:a),Vt(L,Z?u:i),ue&&ue()},K=(L,Z)=>{L._isLeaving=!1,Vt(L,f),Vt(L,m),Vt(L,p),Z&&Z()},ee=L=>(Z,ue)=>{const Ae=L?H:x,ne=()=>O(Z,L,ue);Qt(Ae,[Z,ne]),li(()=>{Vt(Z,L?l:o),_t(Z,L?c:a),ai(Ae)||ci(Z,s,b,ne)})};return we(t,{onBeforeEnter(L){Qt(T,[L]),_t(L,o),_t(L,i)},onBeforeAppear(L){Qt(z,[L]),_t(L,l),_t(L,u)},onEnter:ee(!1),onAppear:ee(!0),onLeave(L,Z){L._isLeaving=!0;const ue=()=>K(L,Z);_t(L,f),L._enterCancelled?(_t(L,p),zr(L)):(zr(L),_t(L,p)),li(()=>{L._isLeaving&&(Vt(L,f),_t(L,m),ai(P)||ci(L,s,v,ue))}),Qt(P,[L,ue])},onEnterCancelled(L){O(L,!1,void 0,!0),Qt(N,[L])},onAppearCancelled(L){O(L,!0,void 0,!0),Qt($,[L])},onLeaveCancelled(L){K(L),Qt(V,[L])}})}function Kf(e){if(e==null)return null;if(fe(e))return[wr(e.enter),wr(e.leave)];{const t=wr(e);return[t,t]}}function wr(e){return Gc(e)}function _t(e,t){t.split(/\s+/).forEach(n=>n&&e.classList.add(n)),(e[wn]||(e[wn]=new Set)).add(t)}function Vt(e,t){t.split(/\s+/).forEach(s=>s&&e.classList.remove(s));const n=e[wn];n&&(n.delete(t),n.size||(e[wn]=void 0))}function li(e){requestAnimationFrame(()=>{requestAnimationFrame(e)})}let Wf=0;function ci(e,t,n,s){const r=e._endId=++Wf,o=()=>{r===e._endId&&s()};if(n!=null)return setTimeout(o,n);const{type:i,timeout:a,propCount:l}=xl(e,t);if(!i)return s();const u=i+"end";let c=0;const f=()=>{e.removeEventListener(u,p),o()},p=m=>{m.target===e&&++c>=l&&f()};setTimeout(()=>{c(n[g]||"").split(", "),r=s(`${Ft}Delay`),o=s(`${Ft}Duration`),i=ui(r,o),a=s(`${Dn}Delay`),l=s(`${Dn}Duration`),u=ui(a,l);let c=null,f=0,p=0;t===Ft?i>0&&(c=Ft,f=i,p=o.length):t===Dn?u>0&&(c=Dn,f=u,p=l.length):(f=Math.max(i,u),c=f>0?i>u?Ft:Dn:null,p=c?c===Ft?o.length:l.length:0);const m=c===Ft&&/\b(?:transform|all)(?:,|$)/.test(s(`${Ft}Property`).toString());return{type:c,timeout:f,propCount:p,hasTransform:m}}function ui(e,t){for(;e.lengthfi(n)+fi(e[s])))}function fi(e){return e==="auto"?0:Number(e.slice(0,-1).replace(",","."))*1e3}function zr(e){return(e?e.ownerDocument:document).body.offsetHeight}function Gf(e,t,n){const s=e[wn];s&&(t=(t?[t,...s]:[...s]).join(" ")),t==null?e.removeAttribute("class"):n?e.setAttribute("class",t):e.className=t}const Us=Symbol("_vod"),Pl=Symbol("_vsh"),Fg={name:"show",beforeMount(e,{value:t},{transition:n}){e[Us]=e.style.display==="none"?"":e.style.display,n&&t?n.beforeEnter(e):Ln(e,t)},mounted(e,{value:t},{transition:n}){n&&t&&n.enter(e)},updated(e,{value:t,oldValue:n},{transition:s}){!t!=!n&&(s?t?(s.beforeEnter(e),Ln(e,!0),s.enter(e)):s.leave(e,()=>{Ln(e,!1)}):Ln(e,t))},beforeUnmount(e,{value:t}){Ln(e,t)}};function Ln(e,t){e.style.display=t?e[Us]:"none",e[Pl]=!t}const zf=Symbol(""),Jf=/(?:^|;)\s*display\s*:/;function Xf(e,t,n){const s=e.style,r=ge(n);let o=!1;if(n&&!r){if(t)if(ge(t))for(const i of t.split(";")){const a=i.slice(0,i.indexOf(":")).trim();n[a]==null&&Ts(s,a,"")}else for(const i in t)n[i]==null&&Ts(s,i,"");for(const i in n)i==="display"&&(o=!0),Ts(s,i,n[i])}else if(r){if(t!==n){const i=s[zf];i&&(n+=";"+i),s.cssText=n,o=Jf.test(n)}}else t&&e.removeAttribute("style");Us in e&&(e[Us]=o?s.display:"",e[Pl]&&(s.display="none"))}const di=/\s*!important$/;function Ts(e,t,n){if(U(n))n.forEach(s=>Ts(e,t,s));else if(n==null&&(n=""),t.startsWith("--"))e.setProperty(t,n);else{const s=Qf(e,t);di.test(n)?e.setProperty(zt(s),n.replace(di,""),"important"):e[s]=n}}const hi=["Webkit","Moz","ms"],Ar={};function Qf(e,t){const n=Ar[t];if(n)return n;let s=tt(t);if(s!=="filter"&&s in e)return Ar[t]=s;s=Gs(s);for(let r=0;rCr||(td.then(()=>Cr=0),Cr=Date.now());function sd(e,t){const n=s=>{if(!s._vts)s._vts=Date.now();else if(s._vts<=n.attached)return;at(rd(s,n.value),t,5,[s])};return n.value=e,n.attached=nd(),n}function rd(e,t){if(U(t)){const n=e.stopImmediatePropagation;return e.stopImmediatePropagation=()=>{n.call(e),e._stopped=!0},t.map(s=>r=>!r._stopped&&s&&s(r))}else return t}const bi=e=>e.charCodeAt(0)===111&&e.charCodeAt(1)===110&&e.charCodeAt(2)>96&&e.charCodeAt(2)<123,od=(e,t,n,s,r,o)=>{const i=r==="svg";t==="class"?Gf(e,s,i):t==="style"?Xf(e,n,s):Ks(t)?ao(t)||Zf(e,t,n,s,o):(t[0]==="."?(t=t.slice(1),!0):t[0]==="^"?(t=t.slice(1),!1):id(e,t,s,i))?(gi(e,t,s),!e.tagName.includes("-")&&(t==="value"||t==="checked"||t==="selected")&&mi(e,t,s,i,o,t!=="value")):e._isVueCE&&(/[A-Z]/.test(t)||!ge(s))?gi(e,tt(t),s,o,t):(t==="true-value"?e._trueValue=s:t==="false-value"&&(e._falseValue=s),mi(e,t,s,i))};function id(e,t,n,s){if(s)return!!(t==="innerHTML"||t==="textContent"||t in e&&bi(t)&&Q(n));if(t==="spellcheck"||t==="draggable"||t==="translate"||t==="autocorrect"||t==="sandbox"&&e.tagName==="IFRAME"||t==="form"||t==="list"&&e.tagName==="INPUT"||t==="type"&&e.tagName==="TEXTAREA")return!1;if(t==="width"||t==="height"){const r=e.tagName;if(r==="IMG"||r==="VIDEO"||r==="CANVAS"||r==="SOURCE")return!1}return bi(t)&&ge(n)?!1:t in e}const Il=new WeakMap,Nl=new WeakMap,Hs=Symbol("_moveCb"),vi=Symbol("_enterCb"),ad=e=>(delete e.props.mode,e),ld=ad({name:"TransitionGroup",props:we({},jf,{tag:String,moveClass:String}),setup(e,{slots:t}){const n=sr(),s=Bu();let r,o;return Za(()=>{if(!r.length)return;const i=e.moveClass||`${e.name||"v"}-move`;if(!hd(r[0].el,n.vnode.el,i)){r=[];return}r.forEach(ud),r.forEach(fd);const a=r.filter(dd);zr(n.vnode.el),a.forEach(l=>{const u=l.el,c=u.style;_t(u,i),c.transform=c.webkitTransform=c.transitionDuration="";const f=u[Hs]=p=>{p&&p.target!==u||(!p||p.propertyName.endsWith("transform"))&&(u.removeEventListener("transitionend",f),u[Hs]=null,Vt(u,i))};u.addEventListener("transitionend",f)}),r=[]}),()=>{const i=oe(e),a=qf(i);let l=i.tag||Te;if(r=[],o)for(let u=0;u{a.split(/\s+/).forEach(l=>l&&s.classList.remove(l))}),n.split(/\s+/).forEach(a=>a&&s.classList.add(a)),s.style.display="none";const o=t.nodeType===1?t:t.parentNode;o.appendChild(s);const{hasTransform:i}=xl(s);return o.removeChild(s),i}const Gt=e=>{const t=e.props["onUpdate:modelValue"]||!1;return U(t)?n=>Es(t,n):t};function pd(e){e.target.composing=!0}function Ei(e){const t=e.target;t.composing&&(t.composing=!1,t.dispatchEvent(new Event("input")))}const et=Symbol("_assign");function Si(e,t,n){return t&&(e=e.trim()),n&&(e=zs(e)),e}const kg={created(e,{modifiers:{lazy:t,trim:n,number:s}},r){e[et]=Gt(r);const o=s||r.props&&r.props.type==="number";xt(e,t?"change":"input",i=>{i.target.composing||e[et](Si(e.value,n,o))}),(n||o)&&xt(e,"change",()=>{e.value=Si(e.value,n,o)}),t||(xt(e,"compositionstart",pd),xt(e,"compositionend",Ei),xt(e,"change",Ei))},mounted(e,{value:t}){e.value=t??""},beforeUpdate(e,{value:t,oldValue:n,modifiers:{lazy:s,trim:r,number:o}},i){if(e[et]=Gt(i),e.composing)return;const a=(o||e.type==="number")&&!/^0\d/.test(e.value)?zs(e.value):e.value,l=t??"";a!==l&&(document.activeElement===e&&e.type!=="range"&&(s&&t===n||r&&e.value.trim()===l)||(e.value=l))}},Vg={deep:!0,created(e,t,n){e[et]=Gt(n),xt(e,"change",()=>{const s=e._modelValue,r=An(e),o=e.checked,i=e[et];if(U(s)){const a=uo(s,r),l=a!==-1;if(o&&!l)i(s.concat(r));else if(!o&&l){const u=[...s];u.splice(a,1),i(u)}}else if(On(s)){const a=new Set(s);o?a.add(r):a.delete(r),i(a)}else i(Dl(e,o))})},mounted:wi,beforeUpdate(e,t,n){e[et]=Gt(n),wi(e,t,n)}};function wi(e,{value:t,oldValue:n},s){e._modelValue=t;let r;if(U(t))r=uo(t,s.props.value)>-1;else if(On(t))r=t.has(s.props.value);else{if(t===n)return;r=un(t,Dl(e,!0))}e.checked!==r&&(e.checked=r)}const $g={created(e,{value:t},n){e.checked=un(t,n.props.value),e[et]=Gt(n),xt(e,"change",()=>{e[et](An(e))})},beforeUpdate(e,{value:t,oldValue:n},s){e[et]=Gt(s),t!==n&&(e.checked=un(t,s.props.value))}},Ug={deep:!0,created(e,{value:t,modifiers:{number:n}},s){const r=On(t);xt(e,"change",()=>{const o=Array.prototype.filter.call(e.options,i=>i.selected).map(i=>n?zs(An(i)):An(i));e[et](e.multiple?r?new Set(o):o:o[0]),e._assigning=!0,as(()=>{e._assigning=!1})}),e[et]=Gt(s)},mounted(e,{value:t}){Ai(e,t)},beforeUpdate(e,t,n){e[et]=Gt(n)},updated(e,{value:t}){e._assigning||Ai(e,t)}};function Ai(e,t){const n=e.multiple,s=U(t);if(!(n&&!s&&!On(t))){for(let r=0,o=e.options.length;rString(u)===String(a)):i.selected=uo(t,a)>-1}else i.selected=t.has(a);else if(un(An(i),t)){e.selectedIndex!==r&&(e.selectedIndex=r);return}}!n&&e.selectedIndex!==-1&&(e.selectedIndex=-1)}}function An(e){return"_value"in e?e._value:e.value}function Dl(e,t){const n=t?"_trueValue":"_falseValue";return n in e?e[n]:t}const md=["ctrl","shift","alt","meta"],gd={stop:e=>e.stopPropagation(),prevent:e=>e.preventDefault(),self:e=>e.target!==e.currentTarget,ctrl:e=>!e.ctrlKey,shift:e=>!e.shiftKey,alt:e=>!e.altKey,meta:e=>!e.metaKey,left:e=>"button"in e&&e.button!==0,middle:e=>"button"in e&&e.button!==1,right:e=>"button"in e&&e.button!==2,exact:(e,t)=>md.some(n=>e[`${n}Key`]&&!t.includes(n))},yd=(e,t)=>{const n=e._withMods||(e._withMods={}),s=t.join(".");return n[s]||(n[s]=(r,...o)=>{for(let i=0;i{const n=e._withKeys||(e._withKeys={}),s=t.join(".");return n[s]||(n[s]=r=>{if(!("key"in r))return;const o=zt(r.key);if(t.some(i=>i===o||_d[i]===o))return e(r)})},bd=we({patchProp:od},Hf);let Ci;function vd(){return Ci||(Ci=df(bd))}const Ll=(...e)=>{const t=vd().createApp(...e),{mount:n}=t;return t.mount=s=>{const r=Sd(s);if(!r)return;const o=t._component;!Q(o)&&!o.render&&!o.template&&(o.template=r.innerHTML),r.nodeType===1&&(r.textContent="");const i=n(r,!1,Ed(r));return r instanceof Element&&(r.removeAttribute("v-cloak"),r.setAttribute("data-v-app","")),i},t};function Ed(e){if(e instanceof SVGElement)return"svg";if(typeof MathMLElement=="function"&&e instanceof MathMLElement)return"mathml"}function Sd(e){return ge(e)?document.querySelector(e):e}/*! + * pinia v2.3.1 + * (c) 2025 Eduardo San Martin Morote + * @license MIT + */let Ml;const or=e=>Ml=e,Bl=Symbol();function Jr(e){return e&&typeof e=="object"&&Object.prototype.toString.call(e)==="[object Object]"&&typeof e.toJSON!="function"}var Wn;(function(e){e.direct="direct",e.patchObject="patch object",e.patchFunction="patch function"})(Wn||(Wn={}));function wd(){const e=Sa(!0),t=e.run(()=>Qs({}));let n=[],s=[];const r=bo({install(o){or(r),r._a=o,o.provide(Bl,r),o.config.globalProperties.$pinia=r,s.forEach(i=>n.push(i)),s=[]},use(o){return this._a?n.push(o):s.push(o),this},_p:n,_a:null,_e:e,_s:new Map,state:t});return r}const Fl=()=>{};function Ti(e,t,n,s=Fl){e.push(t);const r=()=>{const o=e.indexOf(t);o>-1&&(e.splice(o,1),s())};return!n&&wa()&&tu(r),r}function pn(e,...t){e.slice().forEach(n=>{n(...t)})}const Ad=e=>e(),Ri=Symbol(),Tr=Symbol();function Xr(e,t){e instanceof Map&&t instanceof Map?t.forEach((n,s)=>e.set(s,n)):e instanceof Set&&t instanceof Set&&t.forEach(e.add,e);for(const n in t){if(!t.hasOwnProperty(n))continue;const s=t[n],r=e[n];Jr(r)&&Jr(s)&&e.hasOwnProperty(n)&&!ve(s)&&!Kt(s)?e[n]=Xr(r,s):e[n]=s}return e}const Cd=Symbol();function Td(e){return!Jr(e)||!e.hasOwnProperty(Cd)}const{assign:$t}=Object;function Rd(e){return!!(ve(e)&&e.effect)}function Od(e,t,n,s){const{state:r,actions:o,getters:i}=t,a=n.state.value[e];let l;function u(){a||(n.state.value[e]=r?r():{});const c=Au(n.state.value[e]);return $t(c,o,Object.keys(i||{}).reduce((f,p)=>(f[p]=bo(Re(()=>{or(n);const m=n._s.get(e);return i[p].call(m,m)})),f),{}))}return l=kl(e,u,t,n,s,!0),l}function kl(e,t,n={},s,r,o){let i;const a=$t({actions:{}},n),l={deep:!0};let u,c,f=[],p=[],m;const g=s.state.value[e];!o&&!g&&(s.state.value[e]={}),Qs({});let b;function v($){let O;u=c=!1,typeof $=="function"?($(s.state.value[e]),O={type:Wn.patchFunction,storeId:e,events:m}):(Xr(s.state.value[e],$),O={type:Wn.patchObject,payload:$,storeId:e,events:m});const K=b=Symbol();as().then(()=>{b===K&&(u=!0)}),c=!0,pn(f,O,s.state.value[e])}const T=o?function(){const{state:O}=n,K=O?O():{};this.$patch(ee=>{$t(ee,K)})}:Fl;function x(){i.stop(),f=[],p=[],s._s.delete(e)}const N=($,O="")=>{if(Ri in $)return $[Tr]=O,$;const K=function(){or(s);const ee=Array.from(arguments),L=[],Z=[];function ue(J){L.push(J)}function Ae(J){Z.push(J)}pn(p,{args:ee,name:K[Tr],store:V,after:ue,onError:Ae});let ne;try{ne=$.apply(this&&this.$id===e?this:V,ee)}catch(J){throw pn(Z,J),J}return ne instanceof Promise?ne.then(J=>(pn(L,J),J)).catch(J=>(pn(Z,J),Promise.reject(J))):(pn(L,ne),ne)};return K[Ri]=!0,K[Tr]=O,K},P={_p:s,$id:e,$onAction:Ti.bind(null,p),$patch:v,$reset:T,$subscribe($,O={}){const K=Ti(f,$,O.detached,()=>ee()),ee=i.run(()=>qn(()=>s.state.value[e],L=>{(O.flush==="sync"?c:u)&&$({storeId:e,type:Wn.direct,events:m},L)},$t({},l,O)));return K},$dispose:x},V=os(P);s._s.set(e,V);const H=(s._a&&s._a.runWithContext||Ad)(()=>s._e.run(()=>(i=Sa()).run(()=>t({action:N}))));for(const $ in H){const O=H[$];if(ve(O)&&!Rd(O)||Kt(O))o||(g&&Td(O)&&(ve(O)?O.value=g[$]:Xr(O,g[$])),s.state.value[e][$]=O);else if(typeof O=="function"){const K=N(O,$);H[$]=K,a.actions[$]=O}}return $t(V,H),$t(oe(V),H),Object.defineProperty(V,"$state",{get:()=>s.state.value[e],set:$=>{v(O=>{$t(O,$)})}}),s._p.forEach($=>{$t(V,i.run(()=>$({store:V,app:s._a,pinia:s,options:a})))}),g&&o&&n.hydrate&&n.hydrate(V.$state,g),u=!0,c=!0,V}/*! #__NO_SIDE_EFFECTS__ */function xd(e,t,n){let s,r;const o=typeof t=="function";s=e,r=o?n:t;function i(a,l){const u=rf();return a=a||(u?Je(Bl,null):null),a&&or(a),a=Ml,a._s.has(s)||(o?kl(s,t,r,a):Od(s,r,a)),a._s.get(s)}return i.$id=s,i}var Pd=Object.defineProperty,Oi=Object.getOwnPropertySymbols,Id=Object.prototype.hasOwnProperty,Nd=Object.prototype.propertyIsEnumerable,xi=(e,t,n)=>t in e?Pd(e,t,{enumerable:!0,configurable:!0,writable:!0,value:n}):e[t]=n,Vl=(e,t)=>{for(var n in t||(t={}))Id.call(t,n)&&xi(e,n,t[n]);if(Oi)for(var n of Oi(t))Nd.call(t,n)&&xi(e,n,t[n]);return e},ir=e=>typeof e=="function",ar=e=>typeof e=="string",$l=e=>ar(e)&&e.trim().length>0,Dd=e=>typeof e=="number",en=e=>typeof e>"u",ts=e=>typeof e=="object"&&e!==null,Ld=e=>St(e,"tag")&&$l(e.tag),Ul=e=>window.TouchEvent&&e instanceof TouchEvent,Hl=e=>St(e,"component")&&jl(e.component),Md=e=>ir(e)||ts(e),jl=e=>!en(e)&&(ar(e)||Md(e)||Hl(e)),Pi=e=>ts(e)&&["height","width","right","left","top","bottom"].every(t=>Dd(e[t])),St=(e,t)=>(ts(e)||ir(e))&&t in e,Bd=(e=>()=>e++)(0);function Rr(e){return Ul(e)?e.targetTouches[0].clientX:e.clientX}function Ii(e){return Ul(e)?e.targetTouches[0].clientY:e.clientY}var Fd=e=>{en(e.remove)?e.parentNode&&e.parentNode.removeChild(e):e.remove()},cs=e=>Hl(e)?cs(e.component):Ld(e)?Mt({render(){return e}}):typeof e=="string"?e:oe(on(e)),kd=e=>{if(typeof e=="string")return e;const t=St(e,"props")&&ts(e.props)?e.props:{},n=St(e,"listeners")&&ts(e.listeners)?e.listeners:{};return{component:cs(e),props:t,listeners:n}},Vd=()=>typeof window<"u",Ro=class{constructor(){this.allHandlers={}}getHandlers(e){return this.allHandlers[e]||[]}on(e,t){const n=this.getHandlers(e);n.push(t),this.allHandlers[e]=n}off(e,t){const n=this.getHandlers(e);n.splice(n.indexOf(t)>>>0,1)}emit(e,t){this.getHandlers(e).forEach(s=>s(t))}},$d=e=>["on","off","emit"].every(t=>St(e,t)&&ir(e[t])),We;(function(e){e.SUCCESS="success",e.ERROR="error",e.WARNING="warning",e.INFO="info",e.DEFAULT="default"})(We||(We={}));var js;(function(e){e.TOP_LEFT="top-left",e.TOP_CENTER="top-center",e.TOP_RIGHT="top-right",e.BOTTOM_LEFT="bottom-left",e.BOTTOM_CENTER="bottom-center",e.BOTTOM_RIGHT="bottom-right"})(js||(js={}));var Ge;(function(e){e.ADD="add",e.DISMISS="dismiss",e.UPDATE="update",e.CLEAR="clear",e.UPDATE_DEFAULTS="update_defaults"})(Ge||(Ge={}));var rt="Vue-Toastification",st={type:{type:String,default:We.DEFAULT},classNames:{type:[String,Array],default:()=>[]},trueBoolean:{type:Boolean,default:!0}},ql={type:st.type,customIcon:{type:[String,Boolean,Object,Function],default:!0}},Rs={component:{type:[String,Object,Function,Boolean],default:"button"},classNames:st.classNames,showOnHover:{type:Boolean,default:!1},ariaLabel:{type:String,default:"close"}},Qr={timeout:{type:[Number,Boolean],default:5e3},hideProgressBar:{type:Boolean,default:!1},isRunning:{type:Boolean,default:!1}},Kl={transition:{type:[Object,String],default:`${rt}__bounce`}},Ud={position:{type:String,default:js.TOP_RIGHT},draggable:st.trueBoolean,draggablePercent:{type:Number,default:.6},pauseOnFocusLoss:st.trueBoolean,pauseOnHover:st.trueBoolean,closeOnClick:st.trueBoolean,timeout:Qr.timeout,hideProgressBar:Qr.hideProgressBar,toastClassName:st.classNames,bodyClassName:st.classNames,icon:ql.customIcon,closeButton:Rs.component,closeButtonClassName:Rs.classNames,showCloseButtonOnHover:Rs.showOnHover,accessibility:{type:Object,default:()=>({toastRole:"alert",closeButtonLabel:"close"})},rtl:{type:Boolean,default:!1},eventBus:{type:Object,required:!1,default:()=>new Ro}},Hd={id:{type:[String,Number],required:!0,default:0},type:st.type,content:{type:[String,Object,Function],required:!0,default:""},onClick:{type:Function,default:void 0},onClose:{type:Function,default:void 0}},jd={container:{type:[Object,Function],default:()=>document.body},newestOnTop:st.trueBoolean,maxToasts:{type:Number,default:20},transition:Kl.transition,toastDefaults:Object,filterBeforeCreate:{type:Function,default:e=>e},filterToasts:{type:Function,default:e=>e},containerClassName:st.classNames,onMounted:Function,shareAppContext:[Boolean,Object]},It={CORE_TOAST:Ud,TOAST:Hd,CONTAINER:jd,PROGRESS_BAR:Qr,ICON:ql,TRANSITION:Kl,CLOSE_BUTTON:Rs},Wl=Mt({name:"VtProgressBar",props:It.PROGRESS_BAR,data(){return{hasClass:!0}},computed:{style(){return{animationDuration:`${this.timeout}ms`,animationPlayState:this.isRunning?"running":"paused",opacity:this.hideProgressBar?0:1}},cpClass(){return this.hasClass?`${rt}__progress-bar`:""}},watch:{timeout(){this.hasClass=!1,this.$nextTick(()=>this.hasClass=!0)}},mounted(){this.$el.addEventListener("animationend",this.animationEnded)},beforeUnmount(){this.$el.removeEventListener("animationend",this.animationEnded)},methods:{animationEnded(){this.$emit("close-toast")}}});function qd(e,t){return ce(),He("div",{style:rs(e.style),class:Nt(e.cpClass)},null,6)}Wl.render=qd;var Kd=Wl,Gl=Mt({name:"VtCloseButton",props:It.CLOSE_BUTTON,computed:{buttonComponent(){return this.component!==!1?cs(this.component):"button"},classes(){const e=[`${rt}__close-button`];return this.showOnHover&&e.push("show-on-hover"),e.concat(this.classNames)}}}),Wd=Sn(" × ");function Gd(e,t){return ce(),ke(So(e.buttonComponent),nr({"aria-label":e.ariaLabel,class:e.classes},e.$attrs),{default:Me(()=>[Wd]),_:1},16,["aria-label","class"])}Gl.render=Gd;var zd=Gl,zl={},Jd={"aria-hidden":"true",focusable:"false","data-prefix":"fas","data-icon":"check-circle",class:"svg-inline--fa fa-check-circle fa-w-16",role:"img",xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 512 512"},Xd=X("path",{fill:"currentColor",d:"M504 256c0 136.967-111.033 248-248 248S8 392.967 8 256 119.033 8 256 8s248 111.033 248 248zM227.314 387.314l184-184c6.248-6.248 6.248-16.379 0-22.627l-22.627-22.627c-6.248-6.249-16.379-6.249-22.628 0L216 308.118l-70.059-70.059c-6.248-6.248-16.379-6.248-22.628 0l-22.627 22.627c-6.248 6.248-6.248 16.379 0 22.627l104 104c6.249 6.249 16.379 6.249 22.628.001z"},null,-1),Qd=[Xd];function Yd(e,t){return ce(),He("svg",Jd,Qd)}zl.render=Yd;var Zd=zl,Jl={},eh={"aria-hidden":"true",focusable:"false","data-prefix":"fas","data-icon":"info-circle",class:"svg-inline--fa fa-info-circle fa-w-16",role:"img",xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 512 512"},th=X("path",{fill:"currentColor",d:"M256 8C119.043 8 8 119.083 8 256c0 136.997 111.043 248 248 248s248-111.003 248-248C504 119.083 392.957 8 256 8zm0 110c23.196 0 42 18.804 42 42s-18.804 42-42 42-42-18.804-42-42 18.804-42 42-42zm56 254c0 6.627-5.373 12-12 12h-88c-6.627 0-12-5.373-12-12v-24c0-6.627 5.373-12 12-12h12v-64h-12c-6.627 0-12-5.373-12-12v-24c0-6.627 5.373-12 12-12h64c6.627 0 12 5.373 12 12v100h12c6.627 0 12 5.373 12 12v24z"},null,-1),nh=[th];function sh(e,t){return ce(),He("svg",eh,nh)}Jl.render=sh;var Ni=Jl,Xl={},rh={"aria-hidden":"true",focusable:"false","data-prefix":"fas","data-icon":"exclamation-circle",class:"svg-inline--fa fa-exclamation-circle fa-w-16",role:"img",xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 512 512"},oh=X("path",{fill:"currentColor",d:"M504 256c0 136.997-111.043 248-248 248S8 392.997 8 256C8 119.083 119.043 8 256 8s248 111.083 248 248zm-248 50c-25.405 0-46 20.595-46 46s20.595 46 46 46 46-20.595 46-46-20.595-46-46-46zm-43.673-165.346l7.418 136c.347 6.364 5.609 11.346 11.982 11.346h48.546c6.373 0 11.635-4.982 11.982-11.346l7.418-136c.375-6.874-5.098-12.654-11.982-12.654h-63.383c-6.884 0-12.356 5.78-11.981 12.654z"},null,-1),ih=[oh];function ah(e,t){return ce(),He("svg",rh,ih)}Xl.render=ah;var lh=Xl,Ql={},ch={"aria-hidden":"true",focusable:"false","data-prefix":"fas","data-icon":"exclamation-triangle",class:"svg-inline--fa fa-exclamation-triangle fa-w-18",role:"img",xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 576 512"},uh=X("path",{fill:"currentColor",d:"M569.517 440.013C587.975 472.007 564.806 512 527.94 512H48.054c-36.937 0-59.999-40.055-41.577-71.987L246.423 23.985c18.467-32.009 64.72-31.951 83.154 0l239.94 416.028zM288 354c-25.405 0-46 20.595-46 46s20.595 46 46 46 46-20.595 46-46-20.595-46-46-46zm-43.673-165.346l7.418 136c.347 6.364 5.609 11.346 11.982 11.346h48.546c6.373 0 11.635-4.982 11.982-11.346l7.418-136c.375-6.874-5.098-12.654-11.982-12.654h-63.383c-6.884 0-12.356 5.78-11.981 12.654z"},null,-1),fh=[uh];function dh(e,t){return ce(),He("svg",ch,fh)}Ql.render=dh;var hh=Ql,Yl=Mt({name:"VtIcon",props:It.ICON,computed:{customIconChildren(){return St(this.customIcon,"iconChildren")?this.trimValue(this.customIcon.iconChildren):""},customIconClass(){return ar(this.customIcon)?this.trimValue(this.customIcon):St(this.customIcon,"iconClass")?this.trimValue(this.customIcon.iconClass):""},customIconTag(){return St(this.customIcon,"iconTag")?this.trimValue(this.customIcon.iconTag,"i"):"i"},hasCustomIcon(){return this.customIconClass.length>0},component(){return this.hasCustomIcon?this.customIconTag:jl(this.customIcon)?cs(this.customIcon):this.iconTypeComponent},iconTypeComponent(){return{[We.DEFAULT]:Ni,[We.INFO]:Ni,[We.SUCCESS]:Zd,[We.ERROR]:hh,[We.WARNING]:lh}[this.type]},iconClasses(){const e=[`${rt}__icon`];return this.hasCustomIcon?e.concat(this.customIconClass):e}},methods:{trimValue(e,t=""){return $l(e)?e.trim():t}}});function ph(e,t){return ce(),ke(So(e.component),{class:Nt(e.iconClasses)},{default:Me(()=>[Sn(sn(e.customIconChildren),1)]),_:1},8,["class"])}Yl.render=ph;var mh=Yl,Zl=Mt({name:"VtToast",components:{ProgressBar:Kd,CloseButton:zd,Icon:mh},inheritAttrs:!1,props:Object.assign({},It.CORE_TOAST,It.TOAST),data(){return{isRunning:!0,disableTransitions:!1,beingDragged:!1,dragStart:0,dragPos:{x:0,y:0},dragRect:{}}},computed:{classes(){const e=[`${rt}__toast`,`${rt}__toast--${this.type}`,`${this.position}`].concat(this.toastClassName);return this.disableTransitions&&e.push("disable-transition"),this.rtl&&e.push(`${rt}__toast--rtl`),e},bodyClasses(){return[`${rt}__toast-${ar(this.content)?"body":"component-body"}`].concat(this.bodyClassName)},draggableStyle(){return this.dragStart===this.dragPos.x?{}:this.beingDragged?{transform:`translateX(${this.dragDelta}px)`,opacity:1-Math.abs(this.dragDelta/this.removalDistance)}:{transition:"transform 0.2s, opacity 0.2s",transform:"translateX(0)",opacity:1}},dragDelta(){return this.beingDragged?this.dragPos.x-this.dragStart:0},removalDistance(){return Pi(this.dragRect)?(this.dragRect.right-this.dragRect.left)*this.draggablePercent:0}},mounted(){this.draggable&&this.draggableSetup(),this.pauseOnFocusLoss&&this.focusSetup()},beforeUnmount(){this.draggable&&this.draggableCleanup(),this.pauseOnFocusLoss&&this.focusCleanup()},methods:{hasProp:St,getVueComponentFromObj:cs,closeToast(){this.eventBus.emit(Ge.DISMISS,this.id)},clickHandler(){this.onClick&&this.onClick(this.closeToast),this.closeOnClick&&(!this.beingDragged||this.dragStart===this.dragPos.x)&&this.closeToast()},timeoutHandler(){this.closeToast()},hoverPause(){this.pauseOnHover&&(this.isRunning=!1)},hoverPlay(){this.pauseOnHover&&(this.isRunning=!0)},focusPause(){this.isRunning=!1},focusPlay(){this.isRunning=!0},focusSetup(){addEventListener("blur",this.focusPause),addEventListener("focus",this.focusPlay)},focusCleanup(){removeEventListener("blur",this.focusPause),removeEventListener("focus",this.focusPlay)},draggableSetup(){const e=this.$el;e.addEventListener("touchstart",this.onDragStart,{passive:!0}),e.addEventListener("mousedown",this.onDragStart),addEventListener("touchmove",this.onDragMove,{passive:!1}),addEventListener("mousemove",this.onDragMove),addEventListener("touchend",this.onDragEnd),addEventListener("mouseup",this.onDragEnd)},draggableCleanup(){const e=this.$el;e.removeEventListener("touchstart",this.onDragStart),e.removeEventListener("mousedown",this.onDragStart),removeEventListener("touchmove",this.onDragMove),removeEventListener("mousemove",this.onDragMove),removeEventListener("touchend",this.onDragEnd),removeEventListener("mouseup",this.onDragEnd)},onDragStart(e){this.beingDragged=!0,this.dragPos={x:Rr(e),y:Ii(e)},this.dragStart=Rr(e),this.dragRect=this.$el.getBoundingClientRect()},onDragMove(e){this.beingDragged&&(e.preventDefault(),this.isRunning&&(this.isRunning=!1),this.dragPos={x:Rr(e),y:Ii(e)})},onDragEnd(){this.beingDragged&&(Math.abs(this.dragDelta)>=this.removalDistance?(this.disableTransitions=!0,this.$nextTick(()=>this.closeToast())):setTimeout(()=>{this.beingDragged=!1,Pi(this.dragRect)&&this.pauseOnHover&&this.dragRect.bottom>=this.dragPos.y&&this.dragPos.y>=this.dragRect.top&&this.dragRect.left<=this.dragPos.x&&this.dragPos.x<=this.dragRect.right?this.isRunning=!1:this.isRunning=!0}))}}}),gh=["role"];function yh(e,t){const n=Pt("Icon"),s=Pt("CloseButton"),r=Pt("ProgressBar");return ce(),He("div",{class:Nt(e.classes),style:rs(e.draggableStyle),onClick:t[0]||(t[0]=(...o)=>e.clickHandler&&e.clickHandler(...o)),onMouseenter:t[1]||(t[1]=(...o)=>e.hoverPause&&e.hoverPause(...o)),onMouseleave:t[2]||(t[2]=(...o)=>e.hoverPlay&&e.hoverPlay(...o))},[e.icon?(ce(),ke(n,{key:0,"custom-icon":e.icon,type:e.type},null,8,["custom-icon","type"])):ln("v-if",!0),X("div",{role:e.accessibility.toastRole||"alert",class:Nt(e.bodyClasses)},[typeof e.content=="string"?(ce(),He(Te,{key:0},[Sn(sn(e.content),1)],2112)):(ce(),ke(So(e.getVueComponentFromObj(e.content)),nr({key:1,"toast-id":e.id},e.hasProp(e.content,"props")?e.content.props:{},Ju(e.hasProp(e.content,"listeners")?e.content.listeners:{}),{onCloseToast:e.closeToast}),null,16,["toast-id","onCloseToast"]))],10,gh),e.closeButton?(ce(),ke(s,{key:1,component:e.closeButton,"class-names":e.closeButtonClassName,"show-on-hover":e.showCloseButtonOnHover,"aria-label":e.accessibility.closeButtonLabel,onClick:yd(e.closeToast,["stop"])},null,8,["component","class-names","show-on-hover","aria-label","onClick"])):ln("v-if",!0),e.timeout?(ce(),ke(r,{key:2,"is-running":e.isRunning,"hide-progress-bar":e.hideProgressBar,timeout:e.timeout,onCloseToast:e.timeoutHandler},null,8,["is-running","hide-progress-bar","timeout","onCloseToast"])):ln("v-if",!0)],38)}Zl.render=yh;var _h=Zl,ec=Mt({name:"VtTransition",props:It.TRANSITION,emits:["leave"],methods:{hasProp:St,leave(e){e instanceof HTMLElement&&(e.style.left=e.offsetLeft+"px",e.style.top=e.offsetTop+"px",e.style.width=getComputedStyle(e).width,e.style.position="absolute")}}});function bh(e,t){return ce(),ke(cd,{tag:"div","enter-active-class":e.transition.enter?e.transition.enter:`${e.transition}-enter-active`,"move-class":e.transition.move?e.transition.move:`${e.transition}-move`,"leave-active-class":e.transition.leave?e.transition.leave:`${e.transition}-leave-active`,onLeave:e.leave},{default:Me(()=>[zu(e.$slots,"default")]),_:3},8,["enter-active-class","move-class","leave-active-class","onLeave"])}ec.render=bh;var vh=ec,tc=Mt({name:"VueToastification",devtools:{hide:!0},components:{Toast:_h,VtTransition:vh},props:Object.assign({},It.CORE_TOAST,It.CONTAINER,It.TRANSITION),data(){return{count:0,positions:Object.values(js),toasts:{},defaults:{}}},computed:{toastArray(){return Object.values(this.toasts)},filteredToasts(){return this.defaults.filterToasts(this.toastArray)}},beforeMount(){const e=this.eventBus;e.on(Ge.ADD,this.addToast),e.on(Ge.CLEAR,this.clearToasts),e.on(Ge.DISMISS,this.dismissToast),e.on(Ge.UPDATE,this.updateToast),e.on(Ge.UPDATE_DEFAULTS,this.updateDefaults),this.defaults=this.$props},mounted(){this.setup(this.container)},methods:{async setup(e){ir(e)&&(e=await e()),Fd(this.$el),e.appendChild(this.$el)},setToast(e){en(e.id)||(this.toasts[e.id]=e)},addToast(e){e.content=kd(e.content);const t=Object.assign({},this.defaults,e.type&&this.defaults.toastDefaults&&this.defaults.toastDefaults[e.type],e),n=this.defaults.filterBeforeCreate(t,this.toastArray);n&&this.setToast(n)},dismissToast(e){const t=this.toasts[e];!en(t)&&!en(t.onClose)&&t.onClose(),delete this.toasts[e]},clearToasts(){Object.keys(this.toasts).forEach(e=>{this.dismissToast(e)})},getPositionToasts(e){const t=this.filteredToasts.filter(n=>n.position===e).slice(0,this.defaults.maxToasts);return this.defaults.newestOnTop?t.reverse():t},updateDefaults(e){en(e.container)||this.setup(e.container),this.defaults=Object.assign({},this.defaults,e)},updateToast({id:e,options:t,create:n}){this.toasts[e]?(t.timeout&&t.timeout===this.toasts[e].timeout&&t.timeout++,this.setToast(Object.assign({},this.toasts[e],t))):n&&this.addToast(Object.assign({},{id:e},t))},getClasses(e){return[`${rt}__container`,e].concat(this.defaults.containerClassName)}}});function Eh(e,t){const n=Pt("Toast"),s=Pt("VtTransition");return ce(),He("div",null,[(ce(!0),He(Te,null,zo(e.positions,r=>(ce(),He("div",{key:r},[me(s,{transition:e.defaults.transition,class:Nt(e.getClasses(r))},{default:Me(()=>[(ce(!0),He(Te,null,zo(e.getPositionToasts(r),o=>(ce(),ke(n,nr({key:o.id},o),null,16))),128))]),_:2},1032,["transition","class"])]))),128))])}tc.render=Eh;var Sh=tc,Di=(e={},t=!0)=>{const n=e.eventBus=e.eventBus||new Ro;t&&as(()=>{const o=Ll(Sh,Vl({},e)),i=o.mount(document.createElement("div")),a=e.onMounted;if(en(a)||a(i,o),e.shareAppContext){const l=e.shareAppContext;l===!0?console.warn(`[${rt}] App to share context with was not provided.`):(o._context.components=l._context.components,o._context.directives=l._context.directives,o._context.mixins=l._context.mixins,o._context.provides=l._context.provides,o.config.globalProperties=l.config.globalProperties)}});const s=(o,i)=>{const a=Object.assign({},{id:Bd(),type:We.DEFAULT},i,{content:o});return n.emit(Ge.ADD,a),a.id};s.clear=()=>n.emit(Ge.CLEAR,void 0),s.updateDefaults=o=>{n.emit(Ge.UPDATE_DEFAULTS,o)},s.dismiss=o=>{n.emit(Ge.DISMISS,o)};function r(o,{content:i,options:a},l=!1){const u=Object.assign({},a,{content:i});n.emit(Ge.UPDATE,{id:o,options:u,create:l})}return s.update=r,s.success=(o,i)=>s(o,Object.assign({},i,{type:We.SUCCESS})),s.info=(o,i)=>s(o,Object.assign({},i,{type:We.INFO})),s.error=(o,i)=>s(o,Object.assign({},i,{type:We.ERROR})),s.warning=(o,i)=>s(o,Object.assign({},i,{type:We.WARNING})),s},wh=()=>{const e=()=>console.warn(`[${rt}] This plugin does not support SSR!`);return new Proxy(e,{get(){return e}})};function nc(e){return Vd()?$d(e)?Di({eventBus:e},!1):Di(e,!0):wh()}var sc=Symbol("VueToastification"),rc=new Ro,Ah=(e,t)=>{(t==null?void 0:t.shareAppContext)===!0&&(t.shareAppContext=e);const n=nc(Vl({eventBus:rc},t));e.provide(sc,n)},oc=e=>{const t=sr()?Je(sc,void 0):void 0;return t||nc(rc)},Ch=Ah;function ic(e,t){return function(){return e.apply(t,arguments)}}const{toString:Th}=Object.prototype,{getPrototypeOf:Oo}=Object,{iterator:lr,toStringTag:ac}=Symbol,cr=(e=>t=>{const n=Th.call(t);return e[n]||(e[n]=n.slice(8,-1).toLowerCase())})(Object.create(null)),ct=e=>(e=e.toLowerCase(),t=>cr(t)===e),ur=e=>t=>typeof t===e,{isArray:xn}=Array,Cn=ur("undefined");function us(e){return e!==null&&!Cn(e)&&e.constructor!==null&&!Cn(e.constructor)&&je(e.constructor.isBuffer)&&e.constructor.isBuffer(e)}const lc=ct("ArrayBuffer");function Rh(e){let t;return typeof ArrayBuffer<"u"&&ArrayBuffer.isView?t=ArrayBuffer.isView(e):t=e&&e.buffer&&lc(e.buffer),t}const Oh=ur("string"),je=ur("function"),cc=ur("number"),fs=e=>e!==null&&typeof e=="object",xh=e=>e===!0||e===!1,Os=e=>{if(cr(e)!=="object")return!1;const t=Oo(e);return(t===null||t===Object.prototype||Object.getPrototypeOf(t)===null)&&!(ac in e)&&!(lr in e)},Ph=e=>{if(!fs(e)||us(e))return!1;try{return Object.keys(e).length===0&&Object.getPrototypeOf(e)===Object.prototype}catch{return!1}},Ih=ct("Date"),Nh=ct("File"),Dh=ct("Blob"),Lh=ct("FileList"),Mh=e=>fs(e)&&je(e.pipe),Bh=e=>{let t;return e&&(typeof FormData=="function"&&e instanceof FormData||je(e.append)&&((t=cr(e))==="formdata"||t==="object"&&je(e.toString)&&e.toString()==="[object FormData]"))},Fh=ct("URLSearchParams"),[kh,Vh,$h,Uh]=["ReadableStream","Request","Response","Headers"].map(ct),Hh=e=>e.trim?e.trim():e.replace(/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g,"");function ds(e,t,{allOwnKeys:n=!1}={}){if(e===null||typeof e>"u")return;let s,r;if(typeof e!="object"&&(e=[e]),xn(e))for(s=0,r=e.length;s0;)if(r=n[s],t===r.toLowerCase())return r;return null}const tn=typeof globalThis<"u"?globalThis:typeof self<"u"?self:typeof window<"u"?window:global,fc=e=>!Cn(e)&&e!==tn;function Yr(){const{caseless:e,skipUndefined:t}=fc(this)&&this||{},n={},s=(r,o)=>{const i=e&&uc(n,o)||o;Os(n[i])&&Os(r)?n[i]=Yr(n[i],r):Os(r)?n[i]=Yr({},r):xn(r)?n[i]=r.slice():(!t||!Cn(r))&&(n[i]=r)};for(let r=0,o=arguments.length;r(ds(t,(r,o)=>{n&&je(r)?e[o]=ic(r,n):e[o]=r},{allOwnKeys:s}),e),qh=e=>(e.charCodeAt(0)===65279&&(e=e.slice(1)),e),Kh=(e,t,n,s)=>{e.prototype=Object.create(t.prototype,s),e.prototype.constructor=e,Object.defineProperty(e,"super",{value:t.prototype}),n&&Object.assign(e.prototype,n)},Wh=(e,t,n,s)=>{let r,o,i;const a={};if(t=t||{},e==null)return t;do{for(r=Object.getOwnPropertyNames(e),o=r.length;o-- >0;)i=r[o],(!s||s(i,e,t))&&!a[i]&&(t[i]=e[i],a[i]=!0);e=n!==!1&&Oo(e)}while(e&&(!n||n(e,t))&&e!==Object.prototype);return t},Gh=(e,t,n)=>{e=String(e),(n===void 0||n>e.length)&&(n=e.length),n-=t.length;const s=e.indexOf(t,n);return s!==-1&&s===n},zh=e=>{if(!e)return null;if(xn(e))return e;let t=e.length;if(!cc(t))return null;const n=new Array(t);for(;t-- >0;)n[t]=e[t];return n},Jh=(e=>t=>e&&t instanceof e)(typeof Uint8Array<"u"&&Oo(Uint8Array)),Xh=(e,t)=>{const s=(e&&e[lr]).call(e);let r;for(;(r=s.next())&&!r.done;){const o=r.value;t.call(e,o[0],o[1])}},Qh=(e,t)=>{let n;const s=[];for(;(n=e.exec(t))!==null;)s.push(n);return s},Yh=ct("HTMLFormElement"),Zh=e=>e.toLowerCase().replace(/[-_\s]([a-z\d])(\w*)/g,function(n,s,r){return s.toUpperCase()+r}),Li=(({hasOwnProperty:e})=>(t,n)=>e.call(t,n))(Object.prototype),ep=ct("RegExp"),dc=(e,t)=>{const n=Object.getOwnPropertyDescriptors(e),s={};ds(n,(r,o)=>{let i;(i=t(r,o,e))!==!1&&(s[o]=i||r)}),Object.defineProperties(e,s)},tp=e=>{dc(e,(t,n)=>{if(je(e)&&["arguments","caller","callee"].indexOf(n)!==-1)return!1;const s=e[n];if(je(s)){if(t.enumerable=!1,"writable"in t){t.writable=!1;return}t.set||(t.set=()=>{throw Error("Can not rewrite read-only method '"+n+"'")})}})},np=(e,t)=>{const n={},s=r=>{r.forEach(o=>{n[o]=!0})};return xn(e)?s(e):s(String(e).split(t)),n},sp=()=>{},rp=(e,t)=>e!=null&&Number.isFinite(e=+e)?e:t;function op(e){return!!(e&&je(e.append)&&e[ac]==="FormData"&&e[lr])}const ip=e=>{const t=new Array(10),n=(s,r)=>{if(fs(s)){if(t.indexOf(s)>=0)return;if(us(s))return s;if(!("toJSON"in s)){t[r]=s;const o=xn(s)?[]:{};return ds(s,(i,a)=>{const l=n(i,r+1);!Cn(l)&&(o[a]=l)}),t[r]=void 0,o}}return s};return n(e,0)},ap=ct("AsyncFunction"),lp=e=>e&&(fs(e)||je(e))&&je(e.then)&&je(e.catch),hc=((e,t)=>e?setImmediate:t?((n,s)=>(tn.addEventListener("message",({source:r,data:o})=>{r===tn&&o===n&&s.length&&s.shift()()},!1),r=>{s.push(r),tn.postMessage(n,"*")}))(`axios@${Math.random()}`,[]):n=>setTimeout(n))(typeof setImmediate=="function",je(tn.postMessage)),cp=typeof queueMicrotask<"u"?queueMicrotask.bind(tn):typeof process<"u"&&process.nextTick||hc,up=e=>e!=null&&je(e[lr]),_={isArray:xn,isArrayBuffer:lc,isBuffer:us,isFormData:Bh,isArrayBufferView:Rh,isString:Oh,isNumber:cc,isBoolean:xh,isObject:fs,isPlainObject:Os,isEmptyObject:Ph,isReadableStream:kh,isRequest:Vh,isResponse:$h,isHeaders:Uh,isUndefined:Cn,isDate:Ih,isFile:Nh,isBlob:Dh,isRegExp:ep,isFunction:je,isStream:Mh,isURLSearchParams:Fh,isTypedArray:Jh,isFileList:Lh,forEach:ds,merge:Yr,extend:jh,trim:Hh,stripBOM:qh,inherits:Kh,toFlatObject:Wh,kindOf:cr,kindOfTest:ct,endsWith:Gh,toArray:zh,forEachEntry:Xh,matchAll:Qh,isHTMLForm:Yh,hasOwnProperty:Li,hasOwnProp:Li,reduceDescriptors:dc,freezeMethods:tp,toObjectSet:np,toCamelCase:Zh,noop:sp,toFiniteNumber:rp,findKey:uc,global:tn,isContextDefined:fc,isSpecCompliantForm:op,toJSONObject:ip,isAsyncFn:ap,isThenable:lp,setImmediate:hc,asap:cp,isIterable:up};function Y(e,t,n,s,r){Error.call(this),Error.captureStackTrace?Error.captureStackTrace(this,this.constructor):this.stack=new Error().stack,this.message=e,this.name="AxiosError",t&&(this.code=t),n&&(this.config=n),s&&(this.request=s),r&&(this.response=r,this.status=r.status?r.status:null)}_.inherits(Y,Error,{toJSON:function(){return{message:this.message,name:this.name,description:this.description,number:this.number,fileName:this.fileName,lineNumber:this.lineNumber,columnNumber:this.columnNumber,stack:this.stack,config:_.toJSONObject(this.config),code:this.code,status:this.status}}});const pc=Y.prototype,mc={};["ERR_BAD_OPTION_VALUE","ERR_BAD_OPTION","ECONNABORTED","ETIMEDOUT","ERR_NETWORK","ERR_FR_TOO_MANY_REDIRECTS","ERR_DEPRECATED","ERR_BAD_RESPONSE","ERR_BAD_REQUEST","ERR_CANCELED","ERR_NOT_SUPPORT","ERR_INVALID_URL"].forEach(e=>{mc[e]={value:e}});Object.defineProperties(Y,mc);Object.defineProperty(pc,"isAxiosError",{value:!0});Y.from=(e,t,n,s,r,o)=>{const i=Object.create(pc);_.toFlatObject(e,i,function(c){return c!==Error.prototype},u=>u!=="isAxiosError");const a=e&&e.message?e.message:"Error",l=t==null&&e?e.code:t;return Y.call(i,a,l,n,s,r),e&&i.cause==null&&Object.defineProperty(i,"cause",{value:e,configurable:!0}),i.name=e&&e.name||"Error",o&&Object.assign(i,o),i};const fp=null;function Zr(e){return _.isPlainObject(e)||_.isArray(e)}function gc(e){return _.endsWith(e,"[]")?e.slice(0,-2):e}function Mi(e,t,n){return e?e.concat(t).map(function(r,o){return r=gc(r),!n&&o?"["+r+"]":r}).join(n?".":""):t}function dp(e){return _.isArray(e)&&!e.some(Zr)}const hp=_.toFlatObject(_,{},null,function(t){return/^is[A-Z]/.test(t)});function fr(e,t,n){if(!_.isObject(e))throw new TypeError("target must be an object");t=t||new FormData,n=_.toFlatObject(n,{metaTokens:!0,dots:!1,indexes:!1},!1,function(b,v){return!_.isUndefined(v[b])});const s=n.metaTokens,r=n.visitor||c,o=n.dots,i=n.indexes,l=(n.Blob||typeof Blob<"u"&&Blob)&&_.isSpecCompliantForm(t);if(!_.isFunction(r))throw new TypeError("visitor must be a function");function u(g){if(g===null)return"";if(_.isDate(g))return g.toISOString();if(_.isBoolean(g))return g.toString();if(!l&&_.isBlob(g))throw new Y("Blob is not supported. Use a Buffer instead.");return _.isArrayBuffer(g)||_.isTypedArray(g)?l&&typeof Blob=="function"?new Blob([g]):Buffer.from(g):g}function c(g,b,v){let T=g;if(g&&!v&&typeof g=="object"){if(_.endsWith(b,"{}"))b=s?b:b.slice(0,-2),g=JSON.stringify(g);else if(_.isArray(g)&&dp(g)||(_.isFileList(g)||_.endsWith(b,"[]"))&&(T=_.toArray(g)))return b=gc(b),T.forEach(function(N,P){!(_.isUndefined(N)||N===null)&&t.append(i===!0?Mi([b],P,o):i===null?b:b+"[]",u(N))}),!1}return Zr(g)?!0:(t.append(Mi(v,b,o),u(g)),!1)}const f=[],p=Object.assign(hp,{defaultVisitor:c,convertValue:u,isVisitable:Zr});function m(g,b){if(!_.isUndefined(g)){if(f.indexOf(g)!==-1)throw Error("Circular reference detected in "+b.join("."));f.push(g),_.forEach(g,function(T,x){(!(_.isUndefined(T)||T===null)&&r.call(t,T,_.isString(x)?x.trim():x,b,p))===!0&&m(T,b?b.concat(x):[x])}),f.pop()}}if(!_.isObject(e))throw new TypeError("data must be an object");return m(e),t}function Bi(e){const t={"!":"%21","'":"%27","(":"%28",")":"%29","~":"%7E","%20":"+","%00":"\0"};return encodeURIComponent(e).replace(/[!'()~]|%20|%00/g,function(s){return t[s]})}function xo(e,t){this._pairs=[],e&&fr(e,this,t)}const yc=xo.prototype;yc.append=function(t,n){this._pairs.push([t,n])};yc.toString=function(t){const n=t?function(s){return t.call(this,s,Bi)}:Bi;return this._pairs.map(function(r){return n(r[0])+"="+n(r[1])},"").join("&")};function pp(e){return encodeURIComponent(e).replace(/%3A/gi,":").replace(/%24/g,"$").replace(/%2C/gi,",").replace(/%20/g,"+")}function _c(e,t,n){if(!t)return e;const s=n&&n.encode||pp;_.isFunction(n)&&(n={serialize:n});const r=n&&n.serialize;let o;if(r?o=r(t,n):o=_.isURLSearchParams(t)?t.toString():new xo(t,n).toString(s),o){const i=e.indexOf("#");i!==-1&&(e=e.slice(0,i)),e+=(e.indexOf("?")===-1?"?":"&")+o}return e}class Fi{constructor(){this.handlers=[]}use(t,n,s){return this.handlers.push({fulfilled:t,rejected:n,synchronous:s?s.synchronous:!1,runWhen:s?s.runWhen:null}),this.handlers.length-1}eject(t){this.handlers[t]&&(this.handlers[t]=null)}clear(){this.handlers&&(this.handlers=[])}forEach(t){_.forEach(this.handlers,function(s){s!==null&&t(s)})}}const bc={silentJSONParsing:!0,forcedJSONParsing:!0,clarifyTimeoutError:!1},mp=typeof URLSearchParams<"u"?URLSearchParams:xo,gp=typeof FormData<"u"?FormData:null,yp=typeof Blob<"u"?Blob:null,_p={isBrowser:!0,classes:{URLSearchParams:mp,FormData:gp,Blob:yp},protocols:["http","https","file","blob","url","data"]},Po=typeof window<"u"&&typeof document<"u",eo=typeof navigator=="object"&&navigator||void 0,bp=Po&&(!eo||["ReactNative","NativeScript","NS"].indexOf(eo.product)<0),vp=typeof WorkerGlobalScope<"u"&&self instanceof WorkerGlobalScope&&typeof self.importScripts=="function",Ep=Po&&window.location.href||"http://localhost",Sp=Object.freeze(Object.defineProperty({__proto__:null,hasBrowserEnv:Po,hasStandardBrowserEnv:bp,hasStandardBrowserWebWorkerEnv:vp,navigator:eo,origin:Ep},Symbol.toStringTag,{value:"Module"})),Ne={...Sp,..._p};function wp(e,t){return fr(e,new Ne.classes.URLSearchParams,{visitor:function(n,s,r,o){return Ne.isNode&&_.isBuffer(n)?(this.append(s,n.toString("base64")),!1):o.defaultVisitor.apply(this,arguments)},...t})}function Ap(e){return _.matchAll(/\w+|\[(\w*)]/g,e).map(t=>t[0]==="[]"?"":t[1]||t[0])}function Cp(e){const t={},n=Object.keys(e);let s;const r=n.length;let o;for(s=0;s=n.length;return i=!i&&_.isArray(r)?r.length:i,l?(_.hasOwnProp(r,i)?r[i]=[r[i],s]:r[i]=s,!a):((!r[i]||!_.isObject(r[i]))&&(r[i]=[]),t(n,s,r[i],o)&&_.isArray(r[i])&&(r[i]=Cp(r[i])),!a)}if(_.isFormData(e)&&_.isFunction(e.entries)){const n={};return _.forEachEntry(e,(s,r)=>{t(Ap(s),r,n,0)}),n}return null}function Tp(e,t,n){if(_.isString(e))try{return(t||JSON.parse)(e),_.trim(e)}catch(s){if(s.name!=="SyntaxError")throw s}return(n||JSON.stringify)(e)}const hs={transitional:bc,adapter:["xhr","http","fetch"],transformRequest:[function(t,n){const s=n.getContentType()||"",r=s.indexOf("application/json")>-1,o=_.isObject(t);if(o&&_.isHTMLForm(t)&&(t=new FormData(t)),_.isFormData(t))return r?JSON.stringify(vc(t)):t;if(_.isArrayBuffer(t)||_.isBuffer(t)||_.isStream(t)||_.isFile(t)||_.isBlob(t)||_.isReadableStream(t))return t;if(_.isArrayBufferView(t))return t.buffer;if(_.isURLSearchParams(t))return n.setContentType("application/x-www-form-urlencoded;charset=utf-8",!1),t.toString();let a;if(o){if(s.indexOf("application/x-www-form-urlencoded")>-1)return wp(t,this.formSerializer).toString();if((a=_.isFileList(t))||s.indexOf("multipart/form-data")>-1){const l=this.env&&this.env.FormData;return fr(a?{"files[]":t}:t,l&&new l,this.formSerializer)}}return o||r?(n.setContentType("application/json",!1),Tp(t)):t}],transformResponse:[function(t){const n=this.transitional||hs.transitional,s=n&&n.forcedJSONParsing,r=this.responseType==="json";if(_.isResponse(t)||_.isReadableStream(t))return t;if(t&&_.isString(t)&&(s&&!this.responseType||r)){const i=!(n&&n.silentJSONParsing)&&r;try{return JSON.parse(t,this.parseReviver)}catch(a){if(i)throw a.name==="SyntaxError"?Y.from(a,Y.ERR_BAD_RESPONSE,this,null,this.response):a}}return t}],timeout:0,xsrfCookieName:"XSRF-TOKEN",xsrfHeaderName:"X-XSRF-TOKEN",maxContentLength:-1,maxBodyLength:-1,env:{FormData:Ne.classes.FormData,Blob:Ne.classes.Blob},validateStatus:function(t){return t>=200&&t<300},headers:{common:{Accept:"application/json, text/plain, */*","Content-Type":void 0}}};_.forEach(["delete","get","head","post","put","patch"],e=>{hs.headers[e]={}});const Rp=_.toObjectSet(["age","authorization","content-length","content-type","etag","expires","from","host","if-modified-since","if-unmodified-since","last-modified","location","max-forwards","proxy-authorization","referer","retry-after","user-agent"]),Op=e=>{const t={};let n,s,r;return e&&e.split(` +`).forEach(function(i){r=i.indexOf(":"),n=i.substring(0,r).trim().toLowerCase(),s=i.substring(r+1).trim(),!(!n||t[n]&&Rp[n])&&(n==="set-cookie"?t[n]?t[n].push(s):t[n]=[s]:t[n]=t[n]?t[n]+", "+s:s)}),t},ki=Symbol("internals");function Mn(e){return e&&String(e).trim().toLowerCase()}function xs(e){return e===!1||e==null?e:_.isArray(e)?e.map(xs):String(e)}function xp(e){const t=Object.create(null),n=/([^\s,;=]+)\s*(?:=\s*([^,;]+))?/g;let s;for(;s=n.exec(e);)t[s[1]]=s[2];return t}const Pp=e=>/^[-_a-zA-Z0-9^`|~,!#$%&'*+.]+$/.test(e.trim());function Or(e,t,n,s,r){if(_.isFunction(s))return s.call(this,t,n);if(r&&(t=n),!!_.isString(t)){if(_.isString(s))return t.indexOf(s)!==-1;if(_.isRegExp(s))return s.test(t)}}function Ip(e){return e.trim().toLowerCase().replace(/([a-z\d])(\w*)/g,(t,n,s)=>n.toUpperCase()+s)}function Np(e,t){const n=_.toCamelCase(" "+t);["get","set","has"].forEach(s=>{Object.defineProperty(e,s+n,{value:function(r,o,i){return this[s].call(this,t,r,o,i)},configurable:!0})})}let qe=class{constructor(t){t&&this.set(t)}set(t,n,s){const r=this;function o(a,l,u){const c=Mn(l);if(!c)throw new Error("header name must be a non-empty string");const f=_.findKey(r,c);(!f||r[f]===void 0||u===!0||u===void 0&&r[f]!==!1)&&(r[f||l]=xs(a))}const i=(a,l)=>_.forEach(a,(u,c)=>o(u,c,l));if(_.isPlainObject(t)||t instanceof this.constructor)i(t,n);else if(_.isString(t)&&(t=t.trim())&&!Pp(t))i(Op(t),n);else if(_.isObject(t)&&_.isIterable(t)){let a={},l,u;for(const c of t){if(!_.isArray(c))throw TypeError("Object iterator must return a key-value pair");a[u=c[0]]=(l=a[u])?_.isArray(l)?[...l,c[1]]:[l,c[1]]:c[1]}i(a,n)}else t!=null&&o(n,t,s);return this}get(t,n){if(t=Mn(t),t){const s=_.findKey(this,t);if(s){const r=this[s];if(!n)return r;if(n===!0)return xp(r);if(_.isFunction(n))return n.call(this,r,s);if(_.isRegExp(n))return n.exec(r);throw new TypeError("parser must be boolean|regexp|function")}}}has(t,n){if(t=Mn(t),t){const s=_.findKey(this,t);return!!(s&&this[s]!==void 0&&(!n||Or(this,this[s],s,n)))}return!1}delete(t,n){const s=this;let r=!1;function o(i){if(i=Mn(i),i){const a=_.findKey(s,i);a&&(!n||Or(s,s[a],a,n))&&(delete s[a],r=!0)}}return _.isArray(t)?t.forEach(o):o(t),r}clear(t){const n=Object.keys(this);let s=n.length,r=!1;for(;s--;){const o=n[s];(!t||Or(this,this[o],o,t,!0))&&(delete this[o],r=!0)}return r}normalize(t){const n=this,s={};return _.forEach(this,(r,o)=>{const i=_.findKey(s,o);if(i){n[i]=xs(r),delete n[o];return}const a=t?Ip(o):String(o).trim();a!==o&&delete n[o],n[a]=xs(r),s[a]=!0}),this}concat(...t){return this.constructor.concat(this,...t)}toJSON(t){const n=Object.create(null);return _.forEach(this,(s,r)=>{s!=null&&s!==!1&&(n[r]=t&&_.isArray(s)?s.join(", "):s)}),n}[Symbol.iterator](){return Object.entries(this.toJSON())[Symbol.iterator]()}toString(){return Object.entries(this.toJSON()).map(([t,n])=>t+": "+n).join(` +`)}getSetCookie(){return this.get("set-cookie")||[]}get[Symbol.toStringTag](){return"AxiosHeaders"}static from(t){return t instanceof this?t:new this(t)}static concat(t,...n){const s=new this(t);return n.forEach(r=>s.set(r)),s}static accessor(t){const s=(this[ki]=this[ki]={accessors:{}}).accessors,r=this.prototype;function o(i){const a=Mn(i);s[a]||(Np(r,i),s[a]=!0)}return _.isArray(t)?t.forEach(o):o(t),this}};qe.accessor(["Content-Type","Content-Length","Accept","Accept-Encoding","User-Agent","Authorization"]);_.reduceDescriptors(qe.prototype,({value:e},t)=>{let n=t[0].toUpperCase()+t.slice(1);return{get:()=>e,set(s){this[n]=s}}});_.freezeMethods(qe);function xr(e,t){const n=this||hs,s=t||n,r=qe.from(s.headers);let o=s.data;return _.forEach(e,function(a){o=a.call(n,o,r.normalize(),t?t.status:void 0)}),r.normalize(),o}function Ec(e){return!!(e&&e.__CANCEL__)}function Pn(e,t,n){Y.call(this,e??"canceled",Y.ERR_CANCELED,t,n),this.name="CanceledError"}_.inherits(Pn,Y,{__CANCEL__:!0});function Sc(e,t,n){const s=n.config.validateStatus;!n.status||!s||s(n.status)?e(n):t(new Y("Request failed with status code "+n.status,[Y.ERR_BAD_REQUEST,Y.ERR_BAD_RESPONSE][Math.floor(n.status/100)-4],n.config,n.request,n))}function Dp(e){const t=/^([-+\w]{1,25})(:?\/\/|:)/.exec(e);return t&&t[1]||""}function Lp(e,t){e=e||10;const n=new Array(e),s=new Array(e);let r=0,o=0,i;return t=t!==void 0?t:1e3,function(l){const u=Date.now(),c=s[o];i||(i=u),n[r]=l,s[r]=u;let f=o,p=0;for(;f!==r;)p+=n[f++],f=f%e;if(r=(r+1)%e,r===o&&(o=(o+1)%e),u-i{n=c,r=null,o&&(clearTimeout(o),o=null),e(...u)};return[(...u)=>{const c=Date.now(),f=c-n;f>=s?i(u,c):(r=u,o||(o=setTimeout(()=>{o=null,i(r)},s-f)))},()=>r&&i(r)]}const qs=(e,t,n=3)=>{let s=0;const r=Lp(50,250);return Mp(o=>{const i=o.loaded,a=o.lengthComputable?o.total:void 0,l=i-s,u=r(l),c=i<=a;s=i;const f={loaded:i,total:a,progress:a?i/a:void 0,bytes:l,rate:u||void 0,estimated:u&&a&&c?(a-i)/u:void 0,event:o,lengthComputable:a!=null,[t?"download":"upload"]:!0};e(f)},n)},Vi=(e,t)=>{const n=e!=null;return[s=>t[0]({lengthComputable:n,total:e,loaded:s}),t[1]]},$i=e=>(...t)=>_.asap(()=>e(...t)),Bp=Ne.hasStandardBrowserEnv?((e,t)=>n=>(n=new URL(n,Ne.origin),e.protocol===n.protocol&&e.host===n.host&&(t||e.port===n.port)))(new URL(Ne.origin),Ne.navigator&&/(msie|trident)/i.test(Ne.navigator.userAgent)):()=>!0,Fp=Ne.hasStandardBrowserEnv?{write(e,t,n,s,r,o,i){if(typeof document>"u")return;const a=[`${e}=${encodeURIComponent(t)}`];_.isNumber(n)&&a.push(`expires=${new Date(n).toUTCString()}`),_.isString(s)&&a.push(`path=${s}`),_.isString(r)&&a.push(`domain=${r}`),o===!0&&a.push("secure"),_.isString(i)&&a.push(`SameSite=${i}`),document.cookie=a.join("; ")},read(e){if(typeof document>"u")return null;const t=document.cookie.match(new RegExp("(?:^|; )"+e+"=([^;]*)"));return t?decodeURIComponent(t[1]):null},remove(e){this.write(e,"",Date.now()-864e5,"/")}}:{write(){},read(){return null},remove(){}};function kp(e){return/^([a-z][a-z\d+\-.]*:)?\/\//i.test(e)}function Vp(e,t){return t?e.replace(/\/?\/$/,"")+"/"+t.replace(/^\/+/,""):e}function wc(e,t,n){let s=!kp(t);return e&&(s||n==!1)?Vp(e,t):t}const Ui=e=>e instanceof qe?{...e}:e;function dn(e,t){t=t||{};const n={};function s(u,c,f,p){return _.isPlainObject(u)&&_.isPlainObject(c)?_.merge.call({caseless:p},u,c):_.isPlainObject(c)?_.merge({},c):_.isArray(c)?c.slice():c}function r(u,c,f,p){if(_.isUndefined(c)){if(!_.isUndefined(u))return s(void 0,u,f,p)}else return s(u,c,f,p)}function o(u,c){if(!_.isUndefined(c))return s(void 0,c)}function i(u,c){if(_.isUndefined(c)){if(!_.isUndefined(u))return s(void 0,u)}else return s(void 0,c)}function a(u,c,f){if(f in t)return s(u,c);if(f in e)return s(void 0,u)}const l={url:o,method:o,data:o,baseURL:i,transformRequest:i,transformResponse:i,paramsSerializer:i,timeout:i,timeoutMessage:i,withCredentials:i,withXSRFToken:i,adapter:i,responseType:i,xsrfCookieName:i,xsrfHeaderName:i,onUploadProgress:i,onDownloadProgress:i,decompress:i,maxContentLength:i,maxBodyLength:i,beforeRedirect:i,transport:i,httpAgent:i,httpsAgent:i,cancelToken:i,socketPath:i,responseEncoding:i,validateStatus:a,headers:(u,c,f)=>r(Ui(u),Ui(c),f,!0)};return _.forEach(Object.keys({...e,...t}),function(c){const f=l[c]||r,p=f(e[c],t[c],c);_.isUndefined(p)&&f!==a||(n[c]=p)}),n}const Ac=e=>{const t=dn({},e);let{data:n,withXSRFToken:s,xsrfHeaderName:r,xsrfCookieName:o,headers:i,auth:a}=t;if(t.headers=i=qe.from(i),t.url=_c(wc(t.baseURL,t.url,t.allowAbsoluteUrls),e.params,e.paramsSerializer),a&&i.set("Authorization","Basic "+btoa((a.username||"")+":"+(a.password?unescape(encodeURIComponent(a.password)):""))),_.isFormData(n)){if(Ne.hasStandardBrowserEnv||Ne.hasStandardBrowserWebWorkerEnv)i.setContentType(void 0);else if(_.isFunction(n.getHeaders)){const l=n.getHeaders(),u=["content-type","content-length"];Object.entries(l).forEach(([c,f])=>{u.includes(c.toLowerCase())&&i.set(c,f)})}}if(Ne.hasStandardBrowserEnv&&(s&&_.isFunction(s)&&(s=s(t)),s||s!==!1&&Bp(t.url))){const l=r&&o&&Fp.read(o);l&&i.set(r,l)}return t},$p=typeof XMLHttpRequest<"u",Up=$p&&function(e){return new Promise(function(n,s){const r=Ac(e);let o=r.data;const i=qe.from(r.headers).normalize();let{responseType:a,onUploadProgress:l,onDownloadProgress:u}=r,c,f,p,m,g;function b(){m&&m(),g&&g(),r.cancelToken&&r.cancelToken.unsubscribe(c),r.signal&&r.signal.removeEventListener("abort",c)}let v=new XMLHttpRequest;v.open(r.method.toUpperCase(),r.url,!0),v.timeout=r.timeout;function T(){if(!v)return;const N=qe.from("getAllResponseHeaders"in v&&v.getAllResponseHeaders()),V={data:!a||a==="text"||a==="json"?v.responseText:v.response,status:v.status,statusText:v.statusText,headers:N,config:e,request:v};Sc(function(H){n(H),b()},function(H){s(H),b()},V),v=null}"onloadend"in v?v.onloadend=T:v.onreadystatechange=function(){!v||v.readyState!==4||v.status===0&&!(v.responseURL&&v.responseURL.indexOf("file:")===0)||setTimeout(T)},v.onabort=function(){v&&(s(new Y("Request aborted",Y.ECONNABORTED,e,v)),v=null)},v.onerror=function(P){const V=P&&P.message?P.message:"Network Error",z=new Y(V,Y.ERR_NETWORK,e,v);z.event=P||null,s(z),v=null},v.ontimeout=function(){let P=r.timeout?"timeout of "+r.timeout+"ms exceeded":"timeout exceeded";const V=r.transitional||bc;r.timeoutErrorMessage&&(P=r.timeoutErrorMessage),s(new Y(P,V.clarifyTimeoutError?Y.ETIMEDOUT:Y.ECONNABORTED,e,v)),v=null},o===void 0&&i.setContentType(null),"setRequestHeader"in v&&_.forEach(i.toJSON(),function(P,V){v.setRequestHeader(V,P)}),_.isUndefined(r.withCredentials)||(v.withCredentials=!!r.withCredentials),a&&a!=="json"&&(v.responseType=r.responseType),u&&([p,g]=qs(u,!0),v.addEventListener("progress",p)),l&&v.upload&&([f,m]=qs(l),v.upload.addEventListener("progress",f),v.upload.addEventListener("loadend",m)),(r.cancelToken||r.signal)&&(c=N=>{v&&(s(!N||N.type?new Pn(null,e,v):N),v.abort(),v=null)},r.cancelToken&&r.cancelToken.subscribe(c),r.signal&&(r.signal.aborted?c():r.signal.addEventListener("abort",c)));const x=Dp(r.url);if(x&&Ne.protocols.indexOf(x)===-1){s(new Y("Unsupported protocol "+x+":",Y.ERR_BAD_REQUEST,e));return}v.send(o||null)})},Hp=(e,t)=>{const{length:n}=e=e?e.filter(Boolean):[];if(t||n){let s=new AbortController,r;const o=function(u){if(!r){r=!0,a();const c=u instanceof Error?u:this.reason;s.abort(c instanceof Y?c:new Pn(c instanceof Error?c.message:c))}};let i=t&&setTimeout(()=>{i=null,o(new Y(`timeout ${t} of ms exceeded`,Y.ETIMEDOUT))},t);const a=()=>{e&&(i&&clearTimeout(i),i=null,e.forEach(u=>{u.unsubscribe?u.unsubscribe(o):u.removeEventListener("abort",o)}),e=null)};e.forEach(u=>u.addEventListener("abort",o));const{signal:l}=s;return l.unsubscribe=()=>_.asap(a),l}},jp=function*(e,t){let n=e.byteLength;if(n{const r=qp(e,t);let o=0,i,a=l=>{i||(i=!0,s&&s(l))};return new ReadableStream({async pull(l){try{const{done:u,value:c}=await r.next();if(u){a(),l.close();return}let f=c.byteLength;if(n){let p=o+=f;n(p)}l.enqueue(new Uint8Array(c))}catch(u){throw a(u),u}},cancel(l){return a(l),r.return()}},{highWaterMark:2})},ji=64*1024,{isFunction:bs}=_,Wp=(({Request:e,Response:t})=>({Request:e,Response:t}))(_.global),{ReadableStream:qi,TextEncoder:Ki}=_.global,Wi=(e,...t)=>{try{return!!e(...t)}catch{return!1}},Gp=e=>{e=_.merge.call({skipUndefined:!0},Wp,e);const{fetch:t,Request:n,Response:s}=e,r=t?bs(t):typeof fetch=="function",o=bs(n),i=bs(s);if(!r)return!1;const a=r&&bs(qi),l=r&&(typeof Ki=="function"?(g=>b=>g.encode(b))(new Ki):async g=>new Uint8Array(await new n(g).arrayBuffer())),u=o&&a&&Wi(()=>{let g=!1;const b=new n(Ne.origin,{body:new qi,method:"POST",get duplex(){return g=!0,"half"}}).headers.has("Content-Type");return g&&!b}),c=i&&a&&Wi(()=>_.isReadableStream(new s("").body)),f={stream:c&&(g=>g.body)};r&&["text","arrayBuffer","blob","formData","stream"].forEach(g=>{!f[g]&&(f[g]=(b,v)=>{let T=b&&b[g];if(T)return T.call(b);throw new Y(`Response type '${g}' is not supported`,Y.ERR_NOT_SUPPORT,v)})});const p=async g=>{if(g==null)return 0;if(_.isBlob(g))return g.size;if(_.isSpecCompliantForm(g))return(await new n(Ne.origin,{method:"POST",body:g}).arrayBuffer()).byteLength;if(_.isArrayBufferView(g)||_.isArrayBuffer(g))return g.byteLength;if(_.isURLSearchParams(g)&&(g=g+""),_.isString(g))return(await l(g)).byteLength},m=async(g,b)=>{const v=_.toFiniteNumber(g.getContentLength());return v??p(b)};return async g=>{let{url:b,method:v,data:T,signal:x,cancelToken:N,timeout:P,onDownloadProgress:V,onUploadProgress:z,responseType:H,headers:$,withCredentials:O="same-origin",fetchOptions:K}=Ac(g),ee=t||fetch;H=H?(H+"").toLowerCase():"text";let L=Hp([x,N&&N.toAbortSignal()],P),Z=null;const ue=L&&L.unsubscribe&&(()=>{L.unsubscribe()});let Ae;try{if(z&&u&&v!=="get"&&v!=="head"&&(Ae=await m($,T))!==0){let Ee=new n(b,{method:"POST",body:T,duplex:"half"}),be;if(_.isFormData(T)&&(be=Ee.headers.get("content-type"))&&$.setContentType(be),Ee.body){const[ft,nt]=Vi(Ae,qs($i(z)));T=Hi(Ee.body,ji,ft,nt)}}_.isString(O)||(O=O?"include":"omit");const ne=o&&"credentials"in n.prototype,J={...K,signal:L,method:v.toUpperCase(),headers:$.normalize().toJSON(),body:T,duplex:"half",credentials:ne?O:void 0};Z=o&&new n(b,J);let te=await(o?ee(Z,K):ee(b,J));const Xe=c&&(H==="stream"||H==="response");if(c&&(V||Xe&&ue)){const Ee={};["status","statusText","headers"].forEach(dt=>{Ee[dt]=te[dt]});const be=_.toFiniteNumber(te.headers.get("content-length")),[ft,nt]=V&&Vi(be,qs($i(V),!0))||[];te=new s(Hi(te.body,ji,ft,()=>{nt&&nt(),ue&&ue()}),Ee)}H=H||"text";let ut=await f[_.findKey(f,H)||"text"](te,g);return!Xe&&ue&&ue(),await new Promise((Ee,be)=>{Sc(Ee,be,{data:ut,headers:qe.from(te.headers),status:te.status,statusText:te.statusText,config:g,request:Z})})}catch(ne){throw ue&&ue(),ne&&ne.name==="TypeError"&&/Load failed|fetch/i.test(ne.message)?Object.assign(new Y("Network Error",Y.ERR_NETWORK,g,Z),{cause:ne.cause||ne}):Y.from(ne,ne&&ne.code,g,Z)}}},zp=new Map,Cc=e=>{let t=e&&e.env||{};const{fetch:n,Request:s,Response:r}=t,o=[s,r,n];let i=o.length,a=i,l,u,c=zp;for(;a--;)l=o[a],u=c.get(l),u===void 0&&c.set(l,u=a?new Map:Gp(t)),c=u;return u};Cc();const Io={http:fp,xhr:Up,fetch:{get:Cc}};_.forEach(Io,(e,t)=>{if(e){try{Object.defineProperty(e,"name",{value:t})}catch{}Object.defineProperty(e,"adapterName",{value:t})}});const Gi=e=>`- ${e}`,Jp=e=>_.isFunction(e)||e===null||e===!1;function Xp(e,t){e=_.isArray(e)?e:[e];const{length:n}=e;let s,r;const o={};for(let i=0;i`adapter ${l} `+(u===!1?"is not supported by the environment":"is not available in the build"));let a=n?i.length>1?`since : +`+i.map(Gi).join(` +`):" "+Gi(i[0]):"as no adapter specified";throw new Y("There is no suitable adapter to dispatch the request "+a,"ERR_NOT_SUPPORT")}return r}const Tc={getAdapter:Xp,adapters:Io};function Pr(e){if(e.cancelToken&&e.cancelToken.throwIfRequested(),e.signal&&e.signal.aborted)throw new Pn(null,e)}function zi(e){return Pr(e),e.headers=qe.from(e.headers),e.data=xr.call(e,e.transformRequest),["post","put","patch"].indexOf(e.method)!==-1&&e.headers.setContentType("application/x-www-form-urlencoded",!1),Tc.getAdapter(e.adapter||hs.adapter,e)(e).then(function(s){return Pr(e),s.data=xr.call(e,e.transformResponse,s),s.headers=qe.from(s.headers),s},function(s){return Ec(s)||(Pr(e),s&&s.response&&(s.response.data=xr.call(e,e.transformResponse,s.response),s.response.headers=qe.from(s.response.headers))),Promise.reject(s)})}const Rc="1.13.2",dr={};["object","boolean","number","function","string","symbol"].forEach((e,t)=>{dr[e]=function(s){return typeof s===e||"a"+(t<1?"n ":" ")+e}});const Ji={};dr.transitional=function(t,n,s){function r(o,i){return"[Axios v"+Rc+"] Transitional option '"+o+"'"+i+(s?". "+s:"")}return(o,i,a)=>{if(t===!1)throw new Y(r(i," has been removed"+(n?" in "+n:"")),Y.ERR_DEPRECATED);return n&&!Ji[i]&&(Ji[i]=!0,console.warn(r(i," has been deprecated since v"+n+" and will be removed in the near future"))),t?t(o,i,a):!0}};dr.spelling=function(t){return(n,s)=>(console.warn(`${s} is likely a misspelling of ${t}`),!0)};function Qp(e,t,n){if(typeof e!="object")throw new Y("options must be an object",Y.ERR_BAD_OPTION_VALUE);const s=Object.keys(e);let r=s.length;for(;r-- >0;){const o=s[r],i=t[o];if(i){const a=e[o],l=a===void 0||i(a,o,e);if(l!==!0)throw new Y("option "+o+" must be "+l,Y.ERR_BAD_OPTION_VALUE);continue}if(n!==!0)throw new Y("Unknown option "+o,Y.ERR_BAD_OPTION)}}const Ps={assertOptions:Qp,validators:dr},yt=Ps.validators;let cn=class{constructor(t){this.defaults=t||{},this.interceptors={request:new Fi,response:new Fi}}async request(t,n){try{return await this._request(t,n)}catch(s){if(s instanceof Error){let r={};Error.captureStackTrace?Error.captureStackTrace(r):r=new Error;const o=r.stack?r.stack.replace(/^.+\n/,""):"";try{s.stack?o&&!String(s.stack).endsWith(o.replace(/^.+\n.+\n/,""))&&(s.stack+=` +`+o):s.stack=o}catch{}}throw s}}_request(t,n){typeof t=="string"?(n=n||{},n.url=t):n=t||{},n=dn(this.defaults,n);const{transitional:s,paramsSerializer:r,headers:o}=n;s!==void 0&&Ps.assertOptions(s,{silentJSONParsing:yt.transitional(yt.boolean),forcedJSONParsing:yt.transitional(yt.boolean),clarifyTimeoutError:yt.transitional(yt.boolean)},!1),r!=null&&(_.isFunction(r)?n.paramsSerializer={serialize:r}:Ps.assertOptions(r,{encode:yt.function,serialize:yt.function},!0)),n.allowAbsoluteUrls!==void 0||(this.defaults.allowAbsoluteUrls!==void 0?n.allowAbsoluteUrls=this.defaults.allowAbsoluteUrls:n.allowAbsoluteUrls=!0),Ps.assertOptions(n,{baseUrl:yt.spelling("baseURL"),withXsrfToken:yt.spelling("withXSRFToken")},!0),n.method=(n.method||this.defaults.method||"get").toLowerCase();let i=o&&_.merge(o.common,o[n.method]);o&&_.forEach(["delete","get","head","post","put","patch","common"],g=>{delete o[g]}),n.headers=qe.concat(i,o);const a=[];let l=!0;this.interceptors.request.forEach(function(b){typeof b.runWhen=="function"&&b.runWhen(n)===!1||(l=l&&b.synchronous,a.unshift(b.fulfilled,b.rejected))});const u=[];this.interceptors.response.forEach(function(b){u.push(b.fulfilled,b.rejected)});let c,f=0,p;if(!l){const g=[zi.bind(this),void 0];for(g.unshift(...a),g.push(...u),p=g.length,c=Promise.resolve(n);f{if(!s._listeners)return;let o=s._listeners.length;for(;o-- >0;)s._listeners[o](r);s._listeners=null}),this.promise.then=r=>{let o;const i=new Promise(a=>{s.subscribe(a),o=a}).then(r);return i.cancel=function(){s.unsubscribe(o)},i},t(function(o,i,a){s.reason||(s.reason=new Pn(o,i,a),n(s.reason))})}throwIfRequested(){if(this.reason)throw this.reason}subscribe(t){if(this.reason){t(this.reason);return}this._listeners?this._listeners.push(t):this._listeners=[t]}unsubscribe(t){if(!this._listeners)return;const n=this._listeners.indexOf(t);n!==-1&&this._listeners.splice(n,1)}toAbortSignal(){const t=new AbortController,n=s=>{t.abort(s)};return this.subscribe(n),t.signal.unsubscribe=()=>this.unsubscribe(n),t.signal}static source(){let t;return{token:new Oc(function(r){t=r}),cancel:t}}};function Zp(e){return function(n){return e.apply(null,n)}}function em(e){return _.isObject(e)&&e.isAxiosError===!0}const to={Continue:100,SwitchingProtocols:101,Processing:102,EarlyHints:103,Ok:200,Created:201,Accepted:202,NonAuthoritativeInformation:203,NoContent:204,ResetContent:205,PartialContent:206,MultiStatus:207,AlreadyReported:208,ImUsed:226,MultipleChoices:300,MovedPermanently:301,Found:302,SeeOther:303,NotModified:304,UseProxy:305,Unused:306,TemporaryRedirect:307,PermanentRedirect:308,BadRequest:400,Unauthorized:401,PaymentRequired:402,Forbidden:403,NotFound:404,MethodNotAllowed:405,NotAcceptable:406,ProxyAuthenticationRequired:407,RequestTimeout:408,Conflict:409,Gone:410,LengthRequired:411,PreconditionFailed:412,PayloadTooLarge:413,UriTooLong:414,UnsupportedMediaType:415,RangeNotSatisfiable:416,ExpectationFailed:417,ImATeapot:418,MisdirectedRequest:421,UnprocessableEntity:422,Locked:423,FailedDependency:424,TooEarly:425,UpgradeRequired:426,PreconditionRequired:428,TooManyRequests:429,RequestHeaderFieldsTooLarge:431,UnavailableForLegalReasons:451,InternalServerError:500,NotImplemented:501,BadGateway:502,ServiceUnavailable:503,GatewayTimeout:504,HttpVersionNotSupported:505,VariantAlsoNegotiates:506,InsufficientStorage:507,LoopDetected:508,NotExtended:510,NetworkAuthenticationRequired:511,WebServerIsDown:521,ConnectionTimedOut:522,OriginIsUnreachable:523,TimeoutOccurred:524,SslHandshakeFailed:525,InvalidSslCertificate:526};Object.entries(to).forEach(([e,t])=>{to[t]=e});function xc(e){const t=new cn(e),n=ic(cn.prototype.request,t);return _.extend(n,cn.prototype,t,{allOwnKeys:!0}),_.extend(n,t,null,{allOwnKeys:!0}),n.create=function(r){return xc(dn(e,r))},n}const _e=xc(hs);_e.Axios=cn;_e.CanceledError=Pn;_e.CancelToken=Yp;_e.isCancel=Ec;_e.VERSION=Rc;_e.toFormData=fr;_e.AxiosError=Y;_e.Cancel=_e.CanceledError;_e.all=function(t){return Promise.all(t)};_e.spread=Zp;_e.isAxiosError=em;_e.mergeConfig=dn;_e.AxiosHeaders=qe;_e.formToJSON=e=>vc(_.isHTMLForm(e)?new FormData(e):e);_e.getAdapter=Tc.getAdapter;_e.HttpStatusCode=to;_e.default=_e;const{Axios:Kg,AxiosError:Wg,CanceledError:Gg,isCancel:zg,CancelToken:Jg,VERSION:Xg,all:Qg,Cancel:Yg,isAxiosError:Zg,spread:ey,toFormData:ty,AxiosHeaders:ny,HttpStatusCode:sy,formToJSON:ry,getAdapter:oy,mergeConfig:iy}=_e,tm=oc(),R=_e.create({baseURL:"/api/v1",headers:{"Content-Type":"application/json"}});R.interceptors.request.use(e=>{const t=localStorage.getItem("access_token");return t&&(e.headers.Authorization=`Bearer ${t}`),e},e=>Promise.reject(e));R.interceptors.response.use(e=>e,async e=>{var r,o,i,a,l,u,c;const t=e.config;if(((r=e.response)==null?void 0:r.status)===401&&!t._retry){t._retry=!0;try{const f=localStorage.getItem("refresh_token");if(f){const p=await _e.post("/api/v1/auth/refresh",{refresh_token:f}),{access_token:m,refresh_token:g}=p.data;return localStorage.setItem("access_token",m),localStorage.setItem("refresh_token",g),t.headers.Authorization=`Bearer ${m}`,R(t)}}catch(f){return localStorage.removeItem("access_token"),localStorage.removeItem("refresh_token"),window.location.href="/login",Promise.reject(f)}}let n="An error occurred";return(o=e.response)!=null&&o.data&&e.response.data.detail&&(Array.isArray(e.response.data.detail)?n=e.response.data.detail.map(f=>`${f.loc.join(".")}: ${f.msg}`).join(", "):n=e.response.data.detail),console.error("API Error Details:",{status:(i=e.response)==null?void 0:i.status,statusText:(a=e.response)==null?void 0:a.statusText,data:(l=e.response)==null?void 0:l.data,headers:(u=e.response)==null?void 0:u.headers}),((c=t.url)==null?void 0:c.includes("/auth/login"))||tm.error(n),Promise.reject(e)});const Is={auth:{login:e=>R.post("/auth/login",e),logout:()=>{localStorage.removeItem("access_token"),localStorage.removeItem("refresh_token")},getMe:()=>R.get("/auth/me"),setupTOTP:()=>R.post("/auth/totp/setup"),verifyTOTP:e=>R.post("/auth/totp/verify",{code:e}),disableTOTP:e=>R.post("/auth/totp/disable",{code:e})},users:{list:e=>R.get("/users/",{params:e}),get:e=>R.get(`/users/${e}`),create:e=>R.post("/users/",e),update:(e,t)=>R.put(`/users/${e}`),delete:e=>R.delete(`/users/${e}`),changePassword:e=>R.post("/users/change-password",e)},proxmox:{listHosts:e=>R.get("/proxmox/",{params:e}),getHost:e=>R.get(`/proxmox/${e}`),createHost:e=>R.post("/proxmox/",e),updateHost:(e,t)=>R.put(`/proxmox/${e}`,t),deleteHost:e=>R.delete(`/proxmox/${e}`),testConnection:e=>R.post(`/proxmox/${e}/test`),pollHost:e=>R.post(`/proxmox/${e}/poll`),listNodes:e=>R.get(`/proxmox/${e}/nodes`),getNode:e=>R.get(`/proxmox/nodes/${e}`),getStats:e=>R.get(`/proxmox/${e}/stats`),getNodeStorage:e=>R.get(`/proxmox/nodes/${e}/storage`),getNodeNetwork:e=>R.get(`/proxmox/nodes/${e}/network`)},vms:{list:e=>R.get("/vms/",{params:e}),get:e=>R.get(`/vms/${e}`),create:e=>R.post("/vms/",e),update:(e,t)=>R.put(`/vms/${e}`,t),delete:e=>R.delete(`/vms/${e}`),start:e=>R.post(`/vms/${e}/start`),stop:e=>R.post(`/vms/${e}/stop`),getStatus:e=>R.get(`/vms/${e}/status`),getProgress:e=>R.get(`/vms/${e}/progress`),startByVmid:(e,t)=>R.post(`/vms/control/${t}/${e}/start`),stopByVmid:(e,t)=>R.post(`/vms/control/${t}/${e}/stop`),powerOffByVmid:(e,t)=>R.post(`/vms/control/${t}/${e}/shutdown`),restartByVmid:(e,t)=>R.post(`/vms/control/${t}/${e}/restart`),deleteByVmid:(e,t)=>R.delete(`/vms/control/${t}/${e}/delete`)},isos:{list:e=>R.get("/isos/",{params:e}),get:e=>R.get(`/isos/${e}`),upload:(e,t)=>R.post("/isos/",e,{headers:{"Content-Type":"multipart/form-data"},onUploadProgress:t}),downloadFromUrl:e=>R.post("/isos/download",e),delete:e=>R.delete(`/isos/${e}`),verify:e=>R.post(`/isos/${e}/verify`),getProgress:e=>R.get(`/isos/${e}/progress`)},cloudImages:{list:e=>R.get("/cloud-images/",{params:e}),get:e=>R.get(`/cloud-images/${e}`),create:e=>R.post("/cloud-images/",e),update:(e,t)=>R.put(`/cloud-images/${e}`,t),delete:e=>R.delete(`/cloud-images/${e}`),download:e=>R.post(`/cloud-images/${e}/download`),getProgress:e=>R.get(`/cloud-images/${e}/progress`),getSetupScript:()=>R.get("/cloud-images/setup-script"),checkTemplates:e=>R.get(`/cloud-images/templates/status/${e}`),setupTemplates:e=>R.post("/cloud-images/setup-templates",e),checkSSHStatus:()=>R.get("/cloud-images/ssh-status"),fetchLatest:()=>R.post("/cloud-images/fetch-latest")},updates:{check:e=>R.post(`/updates/vm/${e}/check`),install:e=>R.post(`/updates/vm/${e}/install`),getHistory:(e,t)=>R.get(`/updates/vm/${e}/history`,{params:t}),getLog:e=>R.get(`/updates/log/${e}`),installQemuAgent:e=>R.post(`/updates/vm/${e}/install-qemu-agent`)},dashboard:{getStats:()=>R.get("/dashboard/stats"),getResources:()=>R.get("/dashboard/resources"),getActivity:e=>R.get("/dashboard/activity",{params:e})},bugReport:{submit:e=>R.post("/bug-report/",e)},logs:{getBackendLogs:(e=100)=>R.get("/logs/backend",{params:{lines:e}}),tailBackendLogs:(e=50)=>R.get("/logs/backend/tail",{params:{lines:e}})},docs:{list:()=>R.get("/docs/"),get:(e,t="json")=>R.get(`/docs/${e}`,{params:{format:t}}),downloadPDF:()=>R.get("/docs/download/pdf",{responseType:"blob"})},setup:{enableCloudImages:e=>R.post("/setup/cloud-images/enable",{proxmox_password:e}),enableClusterSSH:e=>R.post("/setup/proxmox-cluster-ssh/enable",{proxmox_password:e})},systemUpdates:{check:()=>R.get("/system-updates/check"),download:()=>R.get("/system-updates/download",{responseType:"blob"}),apply:()=>R.post("/system-updates/apply")},ha:{checkStatus:()=>R.get("/ha/status"),enable:e=>R.post("/ha/enable",e),disable:()=>R.post("/ha/disable"),listGroups:()=>R.get("/ha/groups"),createGroup:e=>R.post("/ha/groups",e),listResources:()=>R.get("/ha/resources"),addResource:e=>R.post("/ha/resources",e),addToGroup:(e,t,n)=>R.post("/ha/resources",{vmid:e,group:t,priority:n}),removeFromGroup:e=>R.delete(`/ha/resources/${e}`)},system:{getInfo:()=>R.get("/system/info")}},Ir=oc(),hr=xd("auth",{state:()=>({user:null,isAuthenticated:!1,loading:!1}),getters:{isAdmin:e=>{var t;return((t=e.user)==null?void 0:t.role)==="admin"},isOperator:e=>{var t;return["admin","operator"].includes((t=e.user)==null?void 0:t.role)},username:e=>{var t;return((t=e.user)==null?void 0:t.username)||""},userRole:e=>{var t;return((t=e.user)==null?void 0:t.role)||"viewer"}},actions:{async login(e){var t,n;this.loading=!0;try{const s=await Is.auth.login(e),{access_token:r,refresh_token:o}=s.data;return localStorage.setItem("access_token",r),localStorage.setItem("refresh_token",o),await this.fetchUser(),Ir.success("Login successful"),{success:!0}}catch(s){const r=((n=(t=s.response)==null?void 0:t.data)==null?void 0:n.detail)||"Login failed";return Ir.error(r),{success:!1,error:r}}finally{this.loading=!1}},async fetchUser(){try{const e=await Is.auth.getMe();this.user=e.data,this.isAuthenticated=!0}catch{this.logout()}},logout(){Is.auth.logout(),this.user=null,this.isAuthenticated=!1,Ir.info("Logged out successfully")},async initializeAuth(){localStorage.getItem("access_token")&&await this.fetchUser()}}}),No=(e,t)=>{const n=e.__vccOpts||e;for(const[s,r]of t)n[s]=r;return n},nm={name:"Sidebar",setup(){const e=hr(),t=Re(()=>e.isAdmin),n=Qs("1.1.0"),s=async()=>{try{const r=await Is.system.getInfo();r.data&&r.data.version&&(n.value=r.data.version)}catch(r){console.warn("Failed to fetch version from API, using fallback:",r)}};return Eo(()=>{s()}),{isAdmin:t,appVersion:n}}},sm={class:"sidebar"},rm={class:"sidebar-nav"},om={class:"sidebar-footer"},im={class:"version"};function am(e,t,n,s,r,o){const i=Pt("router-link");return ce(),He("aside",sm,[t[11]||(t[11]=X("div",{class:"sidebar-header"},[X("h1",{class:"logo"},[Sn("Depl"),X("span",{class:"logo-zero"},"0"),Sn("y")]),X("p",{class:"tagline"},"VM Deployment Panel")],-1)),X("nav",rm,[me(i,{to:"/",class:"nav-item"},{default:Me(()=>[...t[0]||(t[0]=[X("span",{class:"icon"},"📊",-1),X("span",null,"Dashboard",-1)])]),_:1}),me(i,{to:"/vms",class:"nav-item"},{default:Me(()=>[...t[1]||(t[1]=[X("span",{class:"icon"},"🖥️",-1),X("span",null,"Virtual Machines",-1)])]),_:1}),me(i,{to:"/proxmox",class:"nav-item"},{default:Me(()=>[...t[2]||(t[2]=[X("span",{class:"icon"},"🌐",-1),X("span",null,"Proxmox Hosts",-1)])]),_:1}),me(i,{to:"/isos",class:"nav-item"},{default:Me(()=>[...t[3]||(t[3]=[X("span",{class:"icon"},"💿",-1),X("span",null,"ISO Images",-1)])]),_:1}),me(i,{to:"/cloud-images",class:"nav-item"},{default:Me(()=>[...t[4]||(t[4]=[X("span",{class:"icon"},"☁️",-1),X("span",null,"Cloud Images",-1)])]),_:1}),s.isAdmin?(ce(),ke(i,{key:0,to:"/ha-management",class:"nav-item"},{default:Me(()=>[...t[5]||(t[5]=[X("span",{class:"icon"},"🔄",-1),X("span",null,"HA Management",-1)])]),_:1})):ln("",!0),s.isAdmin?(ce(),ke(i,{key:1,to:"/users",class:"nav-item"},{default:Me(()=>[...t[6]||(t[6]=[X("span",{class:"icon"},"👥",-1),X("span",null,"Users",-1)])]),_:1})):ln("",!0),me(i,{to:"/settings",class:"nav-item"},{default:Me(()=>[...t[7]||(t[7]=[X("span",{class:"icon"},"⚙️",-1),X("span",null,"Settings",-1)])]),_:1}),me(i,{to:"/documentation",class:"nav-item"},{default:Me(()=>[...t[8]||(t[8]=[X("span",{class:"icon"},"📖",-1),X("span",null,"Documentation",-1)])]),_:1}),me(i,{to:"/bug-report",class:"nav-item"},{default:Me(()=>[...t[9]||(t[9]=[X("span",{class:"icon"},"🐛",-1),X("span",null,"Report Bug",-1)])]),_:1})]),X("div",om,[X("p",im,"v"+sn(s.appVersion),1),t[10]||(t[10]=X("p",{class:"copyright"},"Open Source",-1))])])}const lm=No(nm,[["render",am],["__scopeId","data-v-a8b8e301"]]);/*! + * vue-router v4.6.3 + * (c) 2025 Eduardo San Martin Morote + * @license MIT + */const yn=typeof document<"u";function Pc(e){return typeof e=="object"||"displayName"in e||"props"in e||"__vccOpts"in e}function cm(e){return e.__esModule||e[Symbol.toStringTag]==="Module"||e.default&&Pc(e.default)}const ie=Object.assign;function Nr(e,t){const n={};for(const s in t){const r=t[s];n[s]=lt(r)?r.map(e):e(r)}return n}const Gn=()=>{},lt=Array.isArray;function Xi(e,t){const n={};for(const s in e)n[s]=s in t?t[s]:e[s];return n}const Ic=/#/g,um=/&/g,fm=/\//g,dm=/=/g,hm=/\?/g,Nc=/\+/g,pm=/%5B/g,mm=/%5D/g,Dc=/%5E/g,gm=/%60/g,Lc=/%7B/g,ym=/%7C/g,Mc=/%7D/g,_m=/%20/g;function Do(e){return e==null?"":encodeURI(""+e).replace(ym,"|").replace(pm,"[").replace(mm,"]")}function bm(e){return Do(e).replace(Lc,"{").replace(Mc,"}").replace(Dc,"^")}function no(e){return Do(e).replace(Nc,"%2B").replace(_m,"+").replace(Ic,"%23").replace(um,"%26").replace(gm,"`").replace(Lc,"{").replace(Mc,"}").replace(Dc,"^")}function vm(e){return no(e).replace(dm,"%3D")}function Em(e){return Do(e).replace(Ic,"%23").replace(hm,"%3F")}function Sm(e){return Em(e).replace(fm,"%2F")}function ns(e){if(e==null)return null;try{return decodeURIComponent(""+e)}catch{}return""+e}const wm=/\/$/,Am=e=>e.replace(wm,"");function Dr(e,t,n="/"){let s,r={},o="",i="";const a=t.indexOf("#");let l=t.indexOf("?");return l=a>=0&&l>a?-1:l,l>=0&&(s=t.slice(0,l),o=t.slice(l,a>0?a:t.length),r=e(o.slice(1))),a>=0&&(s=s||t.slice(0,a),i=t.slice(a,t.length)),s=Om(s??t,n),{fullPath:s+o+i,path:s,query:r,hash:ns(i)}}function Cm(e,t){const n=t.query?e(t.query):"";return t.path+(n&&"?")+n+(t.hash||"")}function Qi(e,t){return!t||!e.toLowerCase().startsWith(t.toLowerCase())?e:e.slice(t.length)||"/"}function Tm(e,t,n){const s=t.matched.length-1,r=n.matched.length-1;return s>-1&&s===r&&Tn(t.matched[s],n.matched[r])&&Bc(t.params,n.params)&&e(t.query)===e(n.query)&&t.hash===n.hash}function Tn(e,t){return(e.aliasOf||e)===(t.aliasOf||t)}function Bc(e,t){if(Object.keys(e).length!==Object.keys(t).length)return!1;for(const n in e)if(!Rm(e[n],t[n]))return!1;return!0}function Rm(e,t){return lt(e)?Yi(e,t):lt(t)?Yi(t,e):e===t}function Yi(e,t){return lt(t)?e.length===t.length&&e.every((n,s)=>n===t[s]):e.length===1&&e[0]===t}function Om(e,t){if(e.startsWith("/"))return e;if(!e)return t;const n=t.split("/"),s=e.split("/"),r=s[s.length-1];(r===".."||r===".")&&s.push("");let o=n.length-1,i,a;for(i=0;i1&&o--;else break;return n.slice(0,o).join("/")+"/"+s.slice(i).join("/")}const kt={path:"/",name:void 0,params:{},query:{},hash:"",fullPath:"/",matched:[],meta:{},redirectedFrom:void 0};let so=function(e){return e.pop="pop",e.push="push",e}({}),Lr=function(e){return e.back="back",e.forward="forward",e.unknown="",e}({});function xm(e){if(!e)if(yn){const t=document.querySelector("base");e=t&&t.getAttribute("href")||"/",e=e.replace(/^\w+:\/\/[^\/]+/,"")}else e="/";return e[0]!=="/"&&e[0]!=="#"&&(e="/"+e),Am(e)}const Pm=/^[^#]+#/;function Im(e,t){return e.replace(Pm,"#")+t}function Nm(e,t){const n=document.documentElement.getBoundingClientRect(),s=e.getBoundingClientRect();return{behavior:t.behavior,left:s.left-n.left-(t.left||0),top:s.top-n.top-(t.top||0)}}const pr=()=>({left:window.scrollX,top:window.scrollY});function Dm(e){let t;if("el"in e){const n=e.el,s=typeof n=="string"&&n.startsWith("#"),r=typeof n=="string"?s?document.getElementById(n.slice(1)):document.querySelector(n):n;if(!r)return;t=Nm(r,e)}else t=e;"scrollBehavior"in document.documentElement.style?window.scrollTo(t):window.scrollTo(t.left!=null?t.left:window.scrollX,t.top!=null?t.top:window.scrollY)}function Zi(e,t){return(history.state?history.state.position-t:-1)+e}const ro=new Map;function Lm(e,t){ro.set(e,t)}function Mm(e){const t=ro.get(e);return ro.delete(e),t}function Bm(e){return typeof e=="string"||e&&typeof e=="object"}function Fc(e){return typeof e=="string"||typeof e=="symbol"}let ye=function(e){return e[e.MATCHER_NOT_FOUND=1]="MATCHER_NOT_FOUND",e[e.NAVIGATION_GUARD_REDIRECT=2]="NAVIGATION_GUARD_REDIRECT",e[e.NAVIGATION_ABORTED=4]="NAVIGATION_ABORTED",e[e.NAVIGATION_CANCELLED=8]="NAVIGATION_CANCELLED",e[e.NAVIGATION_DUPLICATED=16]="NAVIGATION_DUPLICATED",e}({});const kc=Symbol("");ye.MATCHER_NOT_FOUND+"",ye.NAVIGATION_GUARD_REDIRECT+"",ye.NAVIGATION_ABORTED+"",ye.NAVIGATION_CANCELLED+"",ye.NAVIGATION_DUPLICATED+"";function Rn(e,t){return ie(new Error,{type:e,[kc]:!0},t)}function Ct(e,t){return e instanceof Error&&kc in e&&(t==null||!!(e.type&t))}const Fm=["params","query","hash"];function km(e){if(typeof e=="string")return e;if(e.path!=null)return e.path;const t={};for(const n of Fm)n in e&&(t[n]=e[n]);return JSON.stringify(t,null,2)}function Vm(e){const t={};if(e===""||e==="?")return t;const n=(e[0]==="?"?e.slice(1):e).split("&");for(let s=0;sr&&no(r)):[s&&no(s)]).forEach(r=>{r!==void 0&&(t+=(t.length?"&":"")+n,r!=null&&(t+="="+r))})}return t}function $m(e){const t={};for(const n in e){const s=e[n];s!==void 0&&(t[n]=lt(s)?s.map(r=>r==null?null:""+r):s==null?s:""+s)}return t}const Um=Symbol(""),ta=Symbol(""),mr=Symbol(""),Lo=Symbol(""),oo=Symbol("");function Bn(){let e=[];function t(s){return e.push(s),()=>{const r=e.indexOf(s);r>-1&&e.splice(r,1)}}function n(){e=[]}return{add:t,list:()=>e.slice(),reset:n}}function jt(e,t,n,s,r,o=i=>i()){const i=s&&(s.enterCallbacks[r]=s.enterCallbacks[r]||[]);return()=>new Promise((a,l)=>{const u=p=>{p===!1?l(Rn(ye.NAVIGATION_ABORTED,{from:n,to:t})):p instanceof Error?l(p):Bm(p)?l(Rn(ye.NAVIGATION_GUARD_REDIRECT,{from:t,to:p})):(i&&s.enterCallbacks[r]===i&&typeof p=="function"&&i.push(p),a())},c=o(()=>e.call(s&&s.instances[r],t,n,u));let f=Promise.resolve(c);e.length<3&&(f=f.then(u)),f.catch(p=>l(p))})}function Mr(e,t,n,s,r=o=>o()){const o=[];for(const i of e)for(const a in i.components){let l=i.components[a];if(!(t!=="beforeRouteEnter"&&!i.instances[a]))if(Pc(l)){const u=(l.__vccOpts||l)[t];u&&o.push(jt(u,n,s,i,a,r))}else{let u=l();o.push(()=>u.then(c=>{if(!c)throw new Error(`Couldn't resolve component "${a}" at "${i.path}"`);const f=cm(c)?c.default:c;i.mods[a]=c,i.components[a]=f;const p=(f.__vccOpts||f)[t];return p&&jt(p,n,s,i,a,r)()}))}}return o}function Hm(e,t){const n=[],s=[],r=[],o=Math.max(t.matched.length,e.matched.length);for(let i=0;iTn(u,a))?s.push(a):n.push(a));const l=e.matched[i];l&&(t.matched.find(u=>Tn(u,l))||r.push(l))}return[n,s,r]}/*! + * vue-router v4.6.3 + * (c) 2025 Eduardo San Martin Morote + * @license MIT + */let jm=()=>location.protocol+"//"+location.host;function Vc(e,t){const{pathname:n,search:s,hash:r}=t,o=e.indexOf("#");if(o>-1){let i=r.includes(e.slice(o))?e.slice(o).length:1,a=r.slice(i);return a[0]!=="/"&&(a="/"+a),Qi(a,"")}return Qi(n,e)+s+r}function qm(e,t,n,s){let r=[],o=[],i=null;const a=({state:p})=>{const m=Vc(e,location),g=n.value,b=t.value;let v=0;if(p){if(n.value=m,t.value=p,i&&i===g){i=null;return}v=b?p.position-b.position:0}else s(m);r.forEach(T=>{T(n.value,g,{delta:v,type:so.pop,direction:v?v>0?Lr.forward:Lr.back:Lr.unknown})})};function l(){i=n.value}function u(p){r.push(p);const m=()=>{const g=r.indexOf(p);g>-1&&r.splice(g,1)};return o.push(m),m}function c(){if(document.visibilityState==="hidden"){const{history:p}=window;if(!p.state)return;p.replaceState(ie({},p.state,{scroll:pr()}),"")}}function f(){for(const p of o)p();o=[],window.removeEventListener("popstate",a),window.removeEventListener("pagehide",c),document.removeEventListener("visibilitychange",c)}return window.addEventListener("popstate",a),window.addEventListener("pagehide",c),document.addEventListener("visibilitychange",c),{pauseListeners:l,listen:u,destroy:f}}function na(e,t,n,s=!1,r=!1){return{back:e,current:t,forward:n,replaced:s,position:window.history.length,scroll:r?pr():null}}function Km(e){const{history:t,location:n}=window,s={value:Vc(e,n)},r={value:t.state};r.value||o(s.value,{back:null,current:s.value,forward:null,position:t.length-1,replaced:!0,scroll:null},!0);function o(l,u,c){const f=e.indexOf("#"),p=f>-1?(n.host&&document.querySelector("base")?e:e.slice(f))+l:jm()+e+l;try{t[c?"replaceState":"pushState"](u,"",p),r.value=u}catch(m){console.error(m),n[c?"replace":"assign"](p)}}function i(l,u){o(l,ie({},t.state,na(r.value.back,l,r.value.forward,!0),u,{position:r.value.position}),!0),s.value=l}function a(l,u){const c=ie({},r.value,t.state,{forward:l,scroll:pr()});o(c.current,c,!0),o(l,ie({},na(s.value,l,null),{position:c.position+1},u),!1),s.value=l}return{location:s,state:r,push:a,replace:i}}function Wm(e){e=xm(e);const t=Km(e),n=qm(e,t.state,t.location,t.replace);function s(o,i=!0){i||n.pauseListeners(),history.go(o)}const r=ie({location:"",base:e,go:s,createHref:Im.bind(null,e)},t,n);return Object.defineProperty(r,"location",{enumerable:!0,get:()=>t.location.value}),Object.defineProperty(r,"state",{enumerable:!0,get:()=>t.state.value}),r}let nn=function(e){return e[e.Static=0]="Static",e[e.Param=1]="Param",e[e.Group=2]="Group",e}({});var Se=function(e){return e[e.Static=0]="Static",e[e.Param=1]="Param",e[e.ParamRegExp=2]="ParamRegExp",e[e.ParamRegExpEnd=3]="ParamRegExpEnd",e[e.EscapeNext=4]="EscapeNext",e}(Se||{});const Gm={type:nn.Static,value:""},zm=/[a-zA-Z0-9_]/;function Jm(e){if(!e)return[[]];if(e==="/")return[[Gm]];if(!e.startsWith("/"))throw new Error(`Invalid path "${e}"`);function t(m){throw new Error(`ERR (${n})/"${u}": ${m}`)}let n=Se.Static,s=n;const r=[];let o;function i(){o&&r.push(o),o=[]}let a=0,l,u="",c="";function f(){u&&(n===Se.Static?o.push({type:nn.Static,value:u}):n===Se.Param||n===Se.ParamRegExp||n===Se.ParamRegExpEnd?(o.length>1&&(l==="*"||l==="+")&&t(`A repeatable param (${u}) must be alone in its segment. eg: '/:ids+.`),o.push({type:nn.Param,value:u,regexp:c,repeatable:l==="*"||l==="+",optional:l==="*"||l==="?"})):t("Invalid state to consume buffer"),u="")}function p(){u+=l}for(;at.length?t.length===1&&t[0]===Be.Static+Be.Segment?1:-1:0}function $c(e,t){let n=0;const s=e.score,r=t.score;for(;n0&&t[t.length-1]<0}const eg={strict:!1,end:!0,sensitive:!1};function tg(e,t,n){const s=Ym(Jm(e.path),n),r=ie(s,{record:e,parent:t,children:[],alias:[]});return t&&!r.record.aliasOf==!t.record.aliasOf&&t.children.push(r),r}function ng(e,t){const n=[],s=new Map;t=Xi(eg,t);function r(f){return s.get(f)}function o(f,p,m){const g=!m,b=ia(f);b.aliasOf=m&&m.record;const v=Xi(t,f),T=[b];if("alias"in f){const P=typeof f.alias=="string"?[f.alias]:f.alias;for(const V of P)T.push(ia(ie({},b,{components:m?m.record.components:b.components,path:V,aliasOf:m?m.record:b})))}let x,N;for(const P of T){const{path:V}=P;if(p&&V[0]!=="/"){const z=p.record.path,H=z[z.length-1]==="/"?"":"/";P.path=p.record.path+(V&&H+V)}if(x=tg(P,p,v),m?m.alias.push(x):(N=N||x,N!==x&&N.alias.push(x),g&&f.name&&!aa(x)&&i(f.name)),Uc(x)&&l(x),b.children){const z=b.children;for(let H=0;H{i(N)}:Gn}function i(f){if(Fc(f)){const p=s.get(f);p&&(s.delete(f),n.splice(n.indexOf(p),1),p.children.forEach(i),p.alias.forEach(i))}else{const p=n.indexOf(f);p>-1&&(n.splice(p,1),f.record.name&&s.delete(f.record.name),f.children.forEach(i),f.alias.forEach(i))}}function a(){return n}function l(f){const p=og(f,n);n.splice(p,0,f),f.record.name&&!aa(f)&&s.set(f.record.name,f)}function u(f,p){let m,g={},b,v;if("name"in f&&f.name){if(m=s.get(f.name),!m)throw Rn(ye.MATCHER_NOT_FOUND,{location:f});v=m.record.name,g=ie(oa(p.params,m.keys.filter(N=>!N.optional).concat(m.parent?m.parent.keys.filter(N=>N.optional):[]).map(N=>N.name)),f.params&&oa(f.params,m.keys.map(N=>N.name))),b=m.stringify(g)}else if(f.path!=null)b=f.path,m=n.find(N=>N.re.test(b)),m&&(g=m.parse(b),v=m.record.name);else{if(m=p.name?s.get(p.name):n.find(N=>N.re.test(p.path)),!m)throw Rn(ye.MATCHER_NOT_FOUND,{location:f,currentLocation:p});v=m.record.name,g=ie({},p.params,f.params),b=m.stringify(g)}const T=[];let x=m;for(;x;)T.unshift(x.record),x=x.parent;return{name:v,path:b,params:g,matched:T,meta:rg(T)}}e.forEach(f=>o(f));function c(){n.length=0,s.clear()}return{addRoute:o,resolve:u,removeRoute:i,clearRoutes:c,getRoutes:a,getRecordMatcher:r}}function oa(e,t){const n={};for(const s of t)s in e&&(n[s]=e[s]);return n}function ia(e){const t={path:e.path,redirect:e.redirect,name:e.name,meta:e.meta||{},aliasOf:e.aliasOf,beforeEnter:e.beforeEnter,props:sg(e),children:e.children||[],instances:{},leaveGuards:new Set,updateGuards:new Set,enterCallbacks:{},components:"components"in e?e.components||null:e.component&&{default:e.component}};return Object.defineProperty(t,"mods",{value:{}}),t}function sg(e){const t={},n=e.props||!1;if("component"in e)t.default=n;else for(const s in e.components)t[s]=typeof n=="object"?n[s]:n;return t}function aa(e){for(;e;){if(e.record.aliasOf)return!0;e=e.parent}return!1}function rg(e){return e.reduce((t,n)=>ie(t,n.meta),{})}function og(e,t){let n=0,s=t.length;for(;n!==s;){const o=n+s>>1;$c(e,t[o])<0?s=o:n=o+1}const r=ig(e);return r&&(s=t.lastIndexOf(r,s-1)),s}function ig(e){let t=e;for(;t=t.parent;)if(Uc(t)&&$c(e,t)===0)return t}function Uc({record:e}){return!!(e.name||e.components&&Object.keys(e.components).length||e.redirect)}function la(e){const t=Je(mr),n=Je(Lo),s=Re(()=>{const l=on(e.to);return t.resolve(l)}),r=Re(()=>{const{matched:l}=s.value,{length:u}=l,c=l[u-1],f=n.matched;if(!c||!f.length)return-1;const p=f.findIndex(Tn.bind(null,c));if(p>-1)return p;const m=ca(l[u-2]);return u>1&&ca(c)===m&&f[f.length-1].path!==m?f.findIndex(Tn.bind(null,l[u-2])):p}),o=Re(()=>r.value>-1&&fg(n.params,s.value.params)),i=Re(()=>r.value>-1&&r.value===n.matched.length-1&&Bc(n.params,s.value.params));function a(l={}){if(ug(l)){const u=t[on(e.replace)?"replace":"push"](on(e.to)).catch(Gn);return e.viewTransition&&typeof document<"u"&&"startViewTransition"in document&&document.startViewTransition(()=>u),u}return Promise.resolve()}return{route:s,href:Re(()=>s.value.href),isActive:o,isExactActive:i,navigate:a}}function ag(e){return e.length===1?e[0]:e}const lg=Mt({name:"RouterLink",compatConfig:{MODE:3},props:{to:{type:[String,Object],required:!0},replace:Boolean,activeClass:String,exactActiveClass:String,custom:Boolean,ariaCurrentValue:{type:String,default:"page"},viewTransition:Boolean},useLink:la,setup(e,{slots:t}){const n=os(la(e)),{options:s}=Je(mr),r=Re(()=>({[ua(e.activeClass,s.linkActiveClass,"router-link-active")]:n.isActive,[ua(e.exactActiveClass,s.linkExactActiveClass,"router-link-exact-active")]:n.isExactActive}));return()=>{const o=t.default&&ag(t.default(n));return e.custom?o:Tl("a",{"aria-current":n.isExactActive?e.ariaCurrentValue:null,href:n.href,onClick:n.navigate,class:r.value},o)}}}),cg=lg;function ug(e){if(!(e.metaKey||e.altKey||e.ctrlKey||e.shiftKey)&&!e.defaultPrevented&&!(e.button!==void 0&&e.button!==0)){if(e.currentTarget&&e.currentTarget.getAttribute){const t=e.currentTarget.getAttribute("target");if(/\b_blank\b/i.test(t))return}return e.preventDefault&&e.preventDefault(),!0}}function fg(e,t){for(const n in t){const s=t[n],r=e[n];if(typeof s=="string"){if(s!==r)return!1}else if(!lt(r)||r.length!==s.length||s.some((o,i)=>o!==r[i]))return!1}return!0}function ca(e){return e?e.aliasOf?e.aliasOf.path:e.path:""}const ua=(e,t,n)=>e??t??n,dg=Mt({name:"RouterView",inheritAttrs:!1,props:{name:{type:String,default:"default"},route:Object},compatConfig:{MODE:3},setup(e,{attrs:t,slots:n}){const s=Je(oo),r=Re(()=>e.route||s.value),o=Je(ta,0),i=Re(()=>{let u=on(o);const{matched:c}=r.value;let f;for(;(f=c[u])&&!f.components;)u++;return u}),a=Re(()=>r.value.matched[i.value]);ws(ta,Re(()=>i.value+1)),ws(Um,a),ws(oo,r);const l=Qs();return qn(()=>[l.value,a.value,e.name],([u,c,f],[p,m,g])=>{c&&(c.instances[f]=u,m&&m!==c&&u&&u===p&&(c.leaveGuards.size||(c.leaveGuards=m.leaveGuards),c.updateGuards.size||(c.updateGuards=m.updateGuards))),u&&c&&(!m||!Tn(c,m)||!p)&&(c.enterCallbacks[f]||[]).forEach(b=>b(u))},{flush:"post"}),()=>{const u=r.value,c=e.name,f=a.value,p=f&&f.components[c];if(!p)return fa(n.default,{Component:p,route:u});const m=f.props[c],g=m?m===!0?u.params:typeof m=="function"?m(u):m:null,v=Tl(p,ie({},g,t,{onVnodeUnmounted:T=>{T.component.isUnmounted&&(f.instances[c]=null)},ref:l}));return fa(n.default,{Component:v,route:u})||v}}});function fa(e,t){if(!e)return null;const n=e(t);return n.length===1?n[0]:n}const hg=dg;function pg(e){const t=ng(e.routes,e),n=e.parseQuery||Vm,s=e.stringifyQuery||ea,r=e.history,o=Bn(),i=Bn(),a=Bn(),l=Eu(kt);let u=kt;yn&&e.scrollBehavior&&"scrollRestoration"in history&&(history.scrollRestoration="manual");const c=Nr.bind(null,w=>""+w),f=Nr.bind(null,Sm),p=Nr.bind(null,ns);function m(w,k){let B,j;return Fc(w)?(B=t.getRecordMatcher(w),j=k):j=w,t.addRoute(j,B)}function g(w){const k=t.getRecordMatcher(w);k&&t.removeRoute(k)}function b(){return t.getRoutes().map(w=>w.record)}function v(w){return!!t.getRecordMatcher(w)}function T(w,k){if(k=ie({},k||l.value),typeof w=="string"){const y=Dr(n,w,k.path),S=t.resolve({path:y.path},k),A=r.createHref(y.fullPath);return ie(y,S,{params:p(S.params),hash:ns(y.hash),redirectedFrom:void 0,href:A})}let B;if(w.path!=null)B=ie({},w,{path:Dr(n,w.path,k.path).path});else{const y=ie({},w.params);for(const S in y)y[S]==null&&delete y[S];B=ie({},w,{params:f(y)}),k.params=f(k.params)}const j=t.resolve(B,k),se=w.hash||"";j.params=c(p(j.params));const d=Cm(s,ie({},w,{hash:bm(se),path:j.path})),h=r.createHref(d);return ie({fullPath:d,hash:se,query:s===ea?$m(w.query):w.query||{}},j,{redirectedFrom:void 0,href:h})}function x(w){return typeof w=="string"?Dr(n,w,l.value.path):ie({},w)}function N(w,k){if(u!==w)return Rn(ye.NAVIGATION_CANCELLED,{from:k,to:w})}function P(w){return H(w)}function V(w){return P(ie(x(w),{replace:!0}))}function z(w,k){const B=w.matched[w.matched.length-1];if(B&&B.redirect){const{redirect:j}=B;let se=typeof j=="function"?j(w,k):j;return typeof se=="string"&&(se=se.includes("?")||se.includes("#")?se=x(se):{path:se},se.params={}),ie({query:w.query,hash:w.hash,params:se.path!=null?{}:w.params},se)}}function H(w,k){const B=u=T(w),j=l.value,se=w.state,d=w.force,h=w.replace===!0,y=z(B,j);if(y)return H(ie(x(y),{state:typeof y=="object"?ie({},se,y.state):se,force:d,replace:h}),k||B);const S=B;S.redirectedFrom=k;let A;return!d&&Tm(s,j,B)&&(A=Rn(ye.NAVIGATION_DUPLICATED,{to:S,from:j}),Ee(j,j,!0,!1)),(A?Promise.resolve(A):K(S,j)).catch(E=>Ct(E)?Ct(E,ye.NAVIGATION_GUARD_REDIRECT)?E:ut(E):te(E,S,j)).then(E=>{if(E){if(Ct(E,ye.NAVIGATION_GUARD_REDIRECT))return H(ie({replace:h},x(E.to),{state:typeof E.to=="object"?ie({},se,E.to.state):se,force:d}),k||S)}else E=L(S,j,!0,h,se);return ee(S,j,E),E})}function $(w,k){const B=N(w,k);return B?Promise.reject(B):Promise.resolve()}function O(w){const k=nt.values().next().value;return k&&typeof k.runWithContext=="function"?k.runWithContext(w):w()}function K(w,k){let B;const[j,se,d]=Hm(w,k);B=Mr(j.reverse(),"beforeRouteLeave",w,k);for(const y of j)y.leaveGuards.forEach(S=>{B.push(jt(S,w,k))});const h=$.bind(null,w,k);return B.push(h),Qe(B).then(()=>{B=[];for(const y of o.list())B.push(jt(y,w,k));return B.push(h),Qe(B)}).then(()=>{B=Mr(se,"beforeRouteUpdate",w,k);for(const y of se)y.updateGuards.forEach(S=>{B.push(jt(S,w,k))});return B.push(h),Qe(B)}).then(()=>{B=[];for(const y of d)if(y.beforeEnter)if(lt(y.beforeEnter))for(const S of y.beforeEnter)B.push(jt(S,w,k));else B.push(jt(y.beforeEnter,w,k));return B.push(h),Qe(B)}).then(()=>(w.matched.forEach(y=>y.enterCallbacks={}),B=Mr(d,"beforeRouteEnter",w,k,O),B.push(h),Qe(B))).then(()=>{B=[];for(const y of i.list())B.push(jt(y,w,k));return B.push(h),Qe(B)}).catch(y=>Ct(y,ye.NAVIGATION_CANCELLED)?y:Promise.reject(y))}function ee(w,k,B){a.list().forEach(j=>O(()=>j(w,k,B)))}function L(w,k,B,j,se){const d=N(w,k);if(d)return d;const h=k===kt,y=yn?history.state:{};B&&(j||h?r.replace(w.fullPath,ie({scroll:h&&y&&y.scroll},se)):r.push(w.fullPath,se)),l.value=w,Ee(w,k,B,h),ut()}let Z;function ue(){Z||(Z=r.listen((w,k,B)=>{if(!dt.listening)return;const j=T(w),se=z(j,dt.currentRoute.value);if(se){H(ie(se,{replace:!0,force:!0}),j).catch(Gn);return}u=j;const d=l.value;yn&&Lm(Zi(d.fullPath,B.delta),pr()),K(j,d).catch(h=>Ct(h,ye.NAVIGATION_ABORTED|ye.NAVIGATION_CANCELLED)?h:Ct(h,ye.NAVIGATION_GUARD_REDIRECT)?(H(ie(x(h.to),{force:!0}),j).then(y=>{Ct(y,ye.NAVIGATION_ABORTED|ye.NAVIGATION_DUPLICATED)&&!B.delta&&B.type===so.pop&&r.go(-1,!1)}).catch(Gn),Promise.reject()):(B.delta&&r.go(-B.delta,!1),te(h,j,d))).then(h=>{h=h||L(j,d,!1),h&&(B.delta&&!Ct(h,ye.NAVIGATION_CANCELLED)?r.go(-B.delta,!1):B.type===so.pop&&Ct(h,ye.NAVIGATION_ABORTED|ye.NAVIGATION_DUPLICATED)&&r.go(-1,!1)),ee(j,d,h)}).catch(Gn)}))}let Ae=Bn(),ne=Bn(),J;function te(w,k,B){ut(w);const j=ne.list();return j.length?j.forEach(se=>se(w,k,B)):console.error(w),Promise.reject(w)}function Xe(){return J&&l.value!==kt?Promise.resolve():new Promise((w,k)=>{Ae.add([w,k])})}function ut(w){return J||(J=!w,ue(),Ae.list().forEach(([k,B])=>w?B(w):k()),Ae.reset()),w}function Ee(w,k,B,j){const{scrollBehavior:se}=e;if(!yn||!se)return Promise.resolve();const d=!B&&Mm(Zi(w.fullPath,0))||(j||!B)&&history.state&&history.state.scroll||null;return as().then(()=>se(w,k,d)).then(h=>h&&Dm(h)).catch(h=>te(h,w,k))}const be=w=>r.go(w);let ft;const nt=new Set,dt={currentRoute:l,listening:!0,addRoute:m,removeRoute:g,clearRoutes:t.clearRoutes,hasRoute:v,getRoutes:b,resolve:T,options:e,push:P,replace:V,go:be,back:()=>be(-1),forward:()=>be(1),beforeEach:o.add,beforeResolve:i.add,afterEach:a.add,onError:ne.add,isReady:Xe,install(w){w.component("RouterLink",cg),w.component("RouterView",hg),w.config.globalProperties.$router=dt,Object.defineProperty(w.config.globalProperties,"$route",{enumerable:!0,get:()=>on(l)}),yn&&!ft&&l.value===kt&&(ft=!0,P(r.location).catch(j=>{}));const k={};for(const j in kt)Object.defineProperty(k,j,{get:()=>l.value[j],enumerable:!0});w.provide(mr,dt),w.provide(Lo,ka(k)),w.provide(oo,l);const B=w.unmount;nt.add(w),w.unmount=function(){nt.delete(w),nt.size<1&&(u=kt,Z&&Z(),Z=null,l.value=kt,ft=!1,J=!1),B()}}};function Qe(w){return w.reduce((k,B)=>k.then(()=>O(B)),Promise.resolve())}return dt}function mg(){return Je(mr)}function gg(e){return Je(Lo)}const yg={name:"Header",setup(){const e=mg(),t=gg(),n=hr(),s=Re(()=>n.username),r=Re(()=>n.userRole),o=Re(()=>({Dashboard:"Dashboard",VirtualMachines:"Virtual Machines",CreateVM:"Create Virtual Machine",VMDetails:"VM Details",ProxmoxHosts:"Proxmox Hosts",ISOImages:"ISO Images",Users:"User Management",Settings:"Settings"})[t.name]||"Depl0y");return{username:s,userRole:r,pageTitle:o,handleLogout:()=>{n.logout(),e.push("/login")}}}},_g={class:"header"},bg={class:"header-left"},vg={class:"page-title"},Eg={class:"header-right"},Sg={class:"user-info"},wg={class:"username"},Ag={class:"user-role badge badge-info"};function Cg(e,t,n,s,r,o){return ce(),He("header",_g,[X("div",bg,[X("h2",vg,sn(s.pageTitle),1)]),X("div",Eg,[X("div",Sg,[X("span",wg,sn(s.username),1),X("span",Ag,sn(s.userRole),1)]),X("button",{onClick:t[0]||(t[0]=(...i)=>s.handleLogout&&s.handleLogout(...i)),class:"btn btn-outline"}," Logout ")])])}const Tg=No(yg,[["render",Cg],["__scopeId","data-v-eac4cac2"]]),Rg={name:"App",components:{Sidebar:lm,Header:Tg},setup(){const e=hr();return{isAuthenticated:Re(()=>e.isAuthenticated)}}},Og={id:"app",class:"app-container"},xg={class:"content"};function Pg(e,t,n,s,r,o){const i=Pt("Sidebar"),a=Pt("Header"),l=Pt("router-view");return ce(),He("div",Og,[s.isAuthenticated?(ce(),ke(i,{key:0})):ln("",!0),X("div",{class:Nt(["main-content",{"full-width":!s.isAuthenticated}])},[s.isAuthenticated?(ce(),ke(a,{key:0})):ln("",!0),X("main",xg,[me(l)])],2)])}const Ig=No(Rg,[["render",Pg],["__scopeId","data-v-cb84523c"]]),Ng="modulepreload",Dg=function(e){return"/"+e},da={},Ue=function(t,n,s){let r=Promise.resolve();if(n&&n.length>0){document.getElementsByTagName("link");const i=document.querySelector("meta[property=csp-nonce]"),a=(i==null?void 0:i.nonce)||(i==null?void 0:i.getAttribute("nonce"));r=Promise.allSettled(n.map(l=>{if(l=Dg(l),l in da)return;da[l]=!0;const u=l.endsWith(".css"),c=u?'[rel="stylesheet"]':"";if(document.querySelector(`link[href="${l}"]${c}`))return;const f=document.createElement("link");if(f.rel=u?"stylesheet":Ng,u||(f.as="script"),f.crossOrigin="",f.href=l,a&&f.setAttribute("nonce",a),document.head.appendChild(f),u)return new Promise((p,m)=>{f.addEventListener("load",p),f.addEventListener("error",()=>m(new Error(`Unable to preload CSS for ${l}`)))})}))}function o(i){const a=new Event("vite:preloadError",{cancelable:!0});if(a.payload=i,window.dispatchEvent(a),!a.defaultPrevented)throw i}return r.then(i=>{for(const a of i||[])a.status==="rejected"&&o(a.reason);return t().catch(o)})},Hc=pg({history:Wm(),routes:[{path:"/login",name:"Login",component:()=>Ue(()=>import("./Login-C73ePELU.js"),__vite__mapDeps([0,1])),meta:{requiresAuth:!1}},{path:"/",name:"Dashboard",component:()=>Ue(()=>import("./Dashboard-D-lJZ8zJ.js"),__vite__mapDeps([2,3])),meta:{requiresAuth:!0}},{path:"/vms",name:"VirtualMachines",component:()=>Ue(()=>import("./VirtualMachines-B0qwUkBJ.js"),__vite__mapDeps([4,5])),meta:{requiresAuth:!0}},{path:"/vms/create",name:"CreateVM",component:()=>Ue(()=>import("./CreateVM-D6qAFo1d.js"),__vite__mapDeps([6,7])),meta:{requiresAuth:!0,requiresOperator:!0}},{path:"/vms/:id",name:"VMDetails",component:()=>Ue(()=>import("./VMDetails-DmFqDTd6.js"),[]),meta:{requiresAuth:!0}},{path:"/proxmox",name:"ProxmoxHosts",component:()=>Ue(()=>import("./ProxmoxHosts-ShY9t_Gh.js"),__vite__mapDeps([8,9])),meta:{requiresAuth:!0}},{path:"/isos",name:"ISOImages",component:()=>Ue(()=>import("./ISOImages-BihK9fce.js"),__vite__mapDeps([10,11])),meta:{requiresAuth:!0}},{path:"/cloud-images",name:"CloudImages",component:()=>Ue(()=>import("./CloudImages-P8bBuEe-.js"),__vite__mapDeps([12,13])),meta:{requiresAuth:!0}},{path:"/ha-management",name:"HAManagement",component:()=>Ue(()=>import("./HAManagement-BUepIRUl.js"),__vite__mapDeps([14,15])),meta:{requiresAuth:!0,requiresAdmin:!0}},{path:"/users",name:"Users",component:()=>Ue(()=>import("./Users-0cuk11p1.js"),__vite__mapDeps([16,17])),meta:{requiresAuth:!0,requiresAdmin:!0}},{path:"/settings",name:"Settings",component:()=>Ue(()=>import("./Settings-BzMSGQUg.js"),__vite__mapDeps([18,19])),meta:{requiresAuth:!0}},{path:"/documentation",name:"Documentation",component:()=>Ue(()=>import("./Documentation-BwVxcGBy.js"),__vite__mapDeps([20,21])),meta:{requiresAuth:!0}},{path:"/bug-report",name:"BugReport",component:()=>Ue(()=>import("./BugReport-DLW0NuSM.js"),__vite__mapDeps([22,23])),meta:{requiresAuth:!0}},{path:"/:pathMatch(.*)*",name:"NotFound",component:()=>Ue(()=>import("./NotFound-W1QtGFtv.js"),[])}]});Hc.beforeEach(async(e,t,n)=>{const s=hr();!s.isAuthenticated&&localStorage.getItem("access_token")&&await s.initializeAuth(),e.meta.requiresAuth&&!s.isAuthenticated?n("/login"):e.path==="/login"&&s.isAuthenticated||e.meta.requiresAdmin&&!s.isAdmin||e.meta.requiresOperator&&!s.isOperator?n("/"):n()});const gr=Ll(Ig);gr.use(wd());gr.use(Hc);gr.use(Ch,{position:"top-right",timeout:3e3,closeOnClick:!0,pauseOnFocusLoss:!0,pauseOnHover:!0,draggable:!0,draggablePercent:.6,showCloseButtonOnHover:!1,hideProgressBar:!1,closeButton:"button",icon:!0,rtl:!1});gr.mount("#app");export{Ug as A,Bg as B,$g as C,Fg as D,Vg as E,Te as F,tl as G,Mg as T,No as _,X as a,Sn as b,He as c,ln as d,Lg as e,hr as f,Pt as g,me as h,Me as i,Re as j,Eo as k,Is as l,Nt as m,rs as n,ce as o,zo as p,Hg as q,Qs as r,gg as s,sn as t,mg as u,kg as v,yd as w,oc as x,qn as y,ke as z}; diff --git a/src/frontend/dist/favicon.ico b/src/frontend/dist/favicon.ico new file mode 100644 index 0000000..eb3e7f8 --- /dev/null +++ b/src/frontend/dist/favicon.ico @@ -0,0 +1,4 @@ + + + D + diff --git a/src/frontend/dist/index.html b/src/frontend/dist/index.html new file mode 100644 index 0000000..503ffd3 --- /dev/null +++ b/src/frontend/dist/index.html @@ -0,0 +1,14 @@ + + + + + + + Depl0y - VM Deployment Panel + + + + +
    + +