Cockpit仮想マシンプラグインから乗り換え可能なQEMU/KVM操作Web-UI完成版

ここのところ機能追加や修正を行ってきた仮想マシンマネージャですが、一応は満足したので、これで完成版にしようかと。

ホストにインストール

これまでと変わらず、特別にセキュリティ対策はしておらず、LAN内の別のPCからアクセスされてしまうので何かしら対策しながら使ってみてください。

sudo nano install-vm-manage3.sh
sudo bash install-vm-manage3.sh
#!/bin/bash
set -e

if [ "$(id -u)" -ne 0 ]; then
    echo "Error: This script must be run as root." >&2
    exit 1
fi

read -rp "Enter port for VM Manager (default 8090): " PORT
PORT="${PORT:-8090}"

echo "Installing dependencies..."
apt-get update -qq
apt-get install -y -qq python3 python3-venv python3-pip libvirt-daemon-system libvirt-clients qemu-system-x86 qemu-utils ovmf novnc python3-websockify

mkdir -p /opt/vm-manage/templates
mkdir -p /opt/vm-manage/static/css
mkdir -p /opt/vm-manage/static/js

cat << 'ENDOFFILE_APP_PY' > /opt/vm-manage/app.py
#!/usr/bin/env python3
import libvirt
import os
from xml.etree import ElementTree as ET
from flask import Flask, render_template, request, jsonify, redirect, url_for, flash

app = Flask(__name__)
app.secret_key = os.urandom(24)

LIBVIRT_URI = "qemu:///system"


@app.context_processor
def inject_vms():
    try:
        conn = get_conn()
        vms = []
        for dom_id in conn.listDomainsID():
            dom = conn.lookupByID(dom_id)
            vms.append(_vm_info(dom))
        for name in conn.listDefinedDomains():
            dom = conn.lookupByName(name)
            vms.append(_vm_info(dom))
        conn.close()
        vms.sort(key=lambda v: v["name"].lower())
        return {"sidebar_vms": vms}
    except Exception:
        return {"sidebar_vms": []}


def get_conn():
    return libvirt.open(LIBVIRT_URI)


@app.route("/")
def index():
    conn = get_conn()
    vms = []
    for dom_id in conn.listDomainsID():
        dom = conn.lookupByID(dom_id)
        vms.append(_vm_info(dom))
    for name in conn.listDefinedDomains():
        dom = conn.lookupByName(name)
        vms.append(_vm_info(dom))
    conn.close()
    return render_template("index.html", vms=vms)


def _vm_info(dom):
    info = dom.info()
    xml_str = dom.XMLDesc(0)
    root = ET.fromstring(xml_str)
    os_el = root.find(".//os/type")
    os_type_attr = os_el.get("type", "") if os_el is not None else ""
    machine = os_el.get("machine", "") if os_el is not None else ""
    return {
        "id": dom.ID() if dom.isActive() else None,
        "name": dom.name(),
        "state": "running" if dom.isActive() else "stopped",
        "vcpus": info[3],
        "memory_mb": info[2] // 1024,
        "domain_type": root.get("type", ""),
        "machine": machine,
    }


@app.route("/vm/<name>")
def vm_detail(name):
    conn = get_conn()
    try:
        dom = conn.lookupByName(name)
    except libvirt.libvirtError:
        flash(f"VM '{name}' が見つかりません", "error")
        conn.close()
        return redirect(url_for("index"))

    xml_str = dom.XMLDesc(0)
    root = ET.fromstring(xml_str)
    devices = _parse_devices(root)
    is_active = dom.isActive()

    os_el = root.find(".//os/type")
    loader_el = root.find(".//os/loader")
    video_el = root.find(".//video/model")
    tpm_el = root.find(".//tpm")

    os_info = {
        "domain_type": root.get("type", ""),
        "arch": os_el.get("arch", "") if os_el is not None else "",
        "machine": os_el.get("machine", "") if os_el is not None else "",
    }

    vm_config = {
        "uefi": loader_el is not None,
        "secure_boot": False,
        "tpm_enabled": tpm_el is not None,
        "video_model": video_el.get("type", "") if video_el is not None else "",
        "vnc_port": "-1",
        "vnc_listen": "0.0.0.0",
        "spice_enabled": False,
        "spice_port": "-1",
        "spice_listen": "0.0.0.0",
        "sound_enabled": root.find(".//sound") is not None,
        "channel_spice": root.find(".//channel[@type='spicevmc']") is not None,
        "usb_redirector_1": False,
        "usb_redirector_2": False,
    }
    firmware_el = root.find(".//firmware")
    if firmware_el is not None:
        for feat in firmware_el.findall("feature"):
            if feat.get("name") == "secure-boot" and feat.get("enabled") == "yes":
                vm_config["secure_boot"] = True
                break
    redir_count = 0
    for rd in root.findall(".//redirdev"):
        if rd.get("type") == "spicevmc":
            redir_count += 1
            if redir_count == 1:
                vm_config["usb_redirector_1"] = True
            elif redir_count == 2:
                vm_config["usb_redirector_2"] = True
    for g in devices["graphics"]:
        if g["type"] == "vnc":
            vm_config["vnc_port"] = g.get("port", "-1")
            vm_config["vnc_listen"] = g.get("listen_address", g.get("listen", "0.0.0.0"))
        elif g["type"] == "spice":
            vm_config["spice_enabled"] = True
            vm_config["spice_port"] = g.get("port", "-1")
            vm_config["spice_listen"] = g.get("listen_address", g.get("listen", "0.0.0.0"))

    networks = []
    for nname in conn.listNetworks():
        net = conn.networkLookupByName(nname)
        networks.append({"name": nname, "active": net.isActive()})

    conn.close()
    return render_template(
        "vm_detail.html",
        vm=_vm_info(dom),
        xml=xml_str,
        devices=devices,
        os_info=os_info,
        vm_config=vm_config,
        networks=networks,
        is_active=is_active,
    )


def _parse_devices(root):
    devices = {"disks": [], "graphics": [], "networks": [], "hostdevs": []}

    for disk in root.findall(".//disk"):
        d = {
            "type": disk.get("type", ""),
            "device": disk.get("device", "disk"),
            "target_dev": "",
            "target_bus": "",
            "source_file": "",
            "source_dev": "",
            "source_protocol": "",
            "source_name": "",
            "driver_type": "",
        }
        target = disk.find("target")
        if target is not None:
            d["target_dev"] = target.get("dev", "")
            d["target_bus"] = target.get("bus", "")
        source = disk.find("source")
        if source is not None:
            d["source_file"] = source.get("file", "")
            d["source_dev"] = source.get("dev", "")
            d["source_protocol"] = source.get("protocol", "")
            d["source_name"] = source.get("name", "")
        driver = disk.find("driver")
        if driver is not None:
            d["driver_type"] = driver.get("type", "")
        devices["disks"].append(d)

    for graphics in root.findall(".//graphics"):
        g = {
            "type": graphics.get("type", ""),
            "port": graphics.get("port", ""),
            "tlsPort": graphics.get("tlsPort", ""),
            "autoport": graphics.get("autoport", ""),
            "listen": graphics.get("listen", ""),
        }
        listen_el = graphics.find("listen")
        if listen_el is not None:
            g["listen_type"] = listen_el.get("type", "")
            g["listen_address"] = listen_el.get("address", "")
        devices["graphics"].append(g)

    for iface in root.findall(".//interface"):
        n = {
            "type": iface.get("type", ""),
            "mac": "",
            "source_network": "",
            "model": "",
        }
        mac = iface.find("mac")
        if mac is not None:
            n["mac"] = mac.get("address", "")
        source = iface.find("source")
        if source is not None:
            n["source_network"] = source.get("network", "") or source.get("bridge", "")
        model = iface.find("model")
        if model is not None:
            n["model"] = model.get("type", "")
        devices["networks"].append(n)

    for hostdev in root.findall(".//hostdev"):
        h = {"type": hostdev.get("type", ""), "mode": hostdev.get("mode", "")}
        source = hostdev.find("source")
        if source is not None:
            address = source.find("address")
            if address is not None:
                h["domain"] = address.get("domain", "")
                h["bus"] = address.get("bus", "")
                h["slot"] = address.get("slot", "")
                h["function"] = address.get("function", "")
        devices["hostdevs"].append(h)

    for hostdev in root.findall(".//hostdev"):
        if hostdev.get("type") == "usb":
            uh = {"vendor_id": "", "product_id": ""}
            source = hostdev.find("source")
            if source is not None:
                vendor = source.find("vendor")
                product = source.find("product")
                uh["vendor_id"] = vendor.get("id", "") if vendor is not None else ""
                uh["product_id"] = product.get("id", "") if product is not None else ""
            devices.setdefault("usb_hostdevs", []).append(uh)

    return devices


def _get_usb_devices():
    import subprocess
    usb_devices = []
    try:
        result = subprocess.run(
            ["lsusb"], capture_output=True, text=True, timeout=5
        )
        for line in result.stdout.strip().split("\n"):
            if not line.strip():
                continue
            parts = line.split()
            if len(parts) < 6:
                continue
            id_str = parts[5]
            if ":" not in id_str:
                continue
            vendor_id, product_id = id_str.split(":", 1)
            name = " ".join(parts[6:])
            bus = parts[1]
            dev = parts[3].rstrip(":")
            usb_devices.append({
                "vendor_id": vendor_id,
                "product_id": product_id,
                "name": name,
                "bus": bus,
                "device": dev,
                "label": f"{id_str} - {name} (Bus {bus}, Dev {dev})",
            })
    except Exception:
        pass
    return usb_devices


@app.route("/api/usb-devices")
def api_usb_devices():
    return jsonify(_get_usb_devices())


@app.route("/vm/<name>/edit", methods=["GET", "POST"])
def vm_edit(name):
    if request.method == "GET":
        return redirect(url_for("vm_detail", name=name))

    config = request.json
    config["name"] = name

    conn = get_conn()
    try:
        dom = conn.lookupByName(name)
    except libvirt.libvirtError:
        conn.close()
        return jsonify({"error": f"VM '{name}' が見つかりません"}), 404

    if dom.isActive():
        conn.close()
        return jsonify({"error": "VMを停止してから編集してください"}), 400

    config["uuid"] = dom.UUIDString()
    new_xml = _build_edit_xml(config)
    if new_xml is None:
        conn.close()
        return jsonify({"error": "XMLの生成に失敗しました"}), 400

    try:
        import subprocess, tempfile
        with tempfile.NamedTemporaryFile(mode='w', suffix='.xml', delete=False) as f:
            f.write(new_xml)
            tmp_path = f.name
        r = subprocess.run(
            ["sudo", "virsh", "define", tmp_path],
            capture_output=True, text=True, timeout=10
        )
        subprocess.run(["sudo", "rm", "-f", tmp_path], capture_output=True, timeout=5)
        if r.returncode != 0:
            conn.close()
            return jsonify({"error": r.stderr.strip() or r.stdout.strip()}), 400
        conn.close()
        return jsonify({"success": True})
    except Exception as e:
        conn.close()
        return jsonify({"error": str(e)}), 400


def _build_edit_xml(config):
    name = config.get("name", "")
    domain_type = config.get("domain_type", "kvm")
    vcpus = int(config.get("vcpus", 2))
    memory_mb = int(config.get("memory_mb", 4096))
    memory_kb = memory_mb * 1024
    arch = config.get("arch", "x86_64")
    machine = config.get("machine", "pc-q35-10.2")
    uefi = config.get("uefi", False)

    vnc_port = config.get("vnc_port", "") or "-1"
    try:
        int(vnc_port)
    except (ValueError, TypeError):
        vnc_port = "-1"
    vnc_listen = config.get("vnc_listen", "") or "0.0.0.0"
    spice_enabled = config.get("spice_enabled", False)
    spice_port = config.get("spice_port", "") or "-1"
    try:
        int(spice_port)
    except (ValueError, TypeError):
        spice_port = "-1"
    spice_tls_port = config.get("spice_tls_port", "") or ""
    spice_listen = config.get("spice_listen", "") or "0.0.0.0"

    video_model = config.get("video_model", "")
    if not video_model:
        video_model = "qxl" if spice_enabled else "virtio"
    tpm_enabled = config.get("tpm_enabled", False)

    net_type = config.get("net_type", "network")
    net_source = config.get("net_source", "default")
    net_model = config.get("net_model", "virtio")

    existing_disks = config.get("existing_disks", [])
    new_disks = config.get("disks", [])
    iso_paths = config.get("iso_paths", [])
    hostdevs = config.get("hostdevs", [])
    existing_usbs = config.get("existing_usbs", [])
    usb_hostdevs = config.get("usb_hostdevs", [])
    boot_order = config.get("boot_order", [])

    lines = []
    lines.append(f'<domain type="{domain_type}">')
    lines.append(f"  <name>{name}</name>")
    uuid = config.get("uuid", "")
    if uuid:
        lines.append(f"  <uuid>{uuid}</uuid>")
    lines.append(f"  <memory unit='KiB'>{memory_kb}</memory>")
    lines.append(f"  <currentMemory unit='KiB'>{memory_kb}</currentMemory>")
    lines.append(f"  <vcpu placement='static'>{vcpus}</vcpu>")
    if uefi:
        secure_boot = config.get("secure_boot", False)
        if secure_boot:
            lines.append("  <os firmware='efi'>")
            lines.append(f"    <type arch='{arch}' machine='{machine}'>hvm</type>")
            lines.append("    <firmware>")
            lines.append("      <feature enabled='yes' name='enrolled-keys'/>")
            lines.append("      <feature enabled='yes' name='secure-boot'/>")
            lines.append("    </firmware>")
            lines.append("    <loader readonly='yes' secure='yes' type='pflash' format='raw'>/usr/share/OVMF/OVMF_CODE_4M.ms.fd</loader>")
            lines.append(f"    <nvram template='/usr/share/OVMF/OVMF_VARS_4M.ms.fd' templateFormat='raw' format='raw'>/var/lib/libvirt/qemu/nvram/{name}_VARS.fd</nvram>")
        else:
            lines.append("  <os firmware='efi'>")
            lines.append(f"    <type arch='{arch}' machine='{machine}'>hvm</type>")
            lines.append("    <firmware>")
            lines.append("      <feature enabled='no' name='enrolled-keys'/>")
            lines.append("      <feature enabled='no' name='secure-boot'/>")
            lines.append("    </firmware>")
            lines.append("    <loader readonly='yes' secure='no' type='pflash' stateless='yes' format='raw'>/usr/share/ovmf/OVMF.amdsev.fd</loader>")
        if boot_order:
            for dev in boot_order:
                lines.append(f"    <boot dev='{dev}'/>")
        else:
            lines.append("    <boot dev='hd'/>")
        lines.append("    <bootmenu enable='yes'/>")
    else:
        lines.append("  <os>")
        lines.append(f"    <type arch='{arch}' machine='{machine}'>hvm</type>")
        if boot_order:
            for dev in boot_order:
                lines.append(f"    <boot dev='{dev}'/>")
        else:
            lines.append("    <boot dev='hd'/>")
    lines.append("  </os>")
    lines.append("  <features><acpi/><apic/></features>")
    lines.append("  <cpu mode='host-passthrough'/>")
    lines.append("  <clock offset='utc'/>")
    lines.append("  <devices>")

    for ed in existing_disks:
        lines.append(f"    <disk type='{ed['type']}' device='{ed['device']}'>")
        lines.append(f"      <driver name='qemu' type='{ed['driver_type']}'/>")
        if ed["type"] == "file" and ed["source_file"]:
            lines.append(f"      <source file='{ed['source_file']}'/>")
        elif ed["type"] == "block" and ed["source_dev"]:
            lines.append(f"      <source dev='{ed['source_dev']}'/>")
        elif ed["type"] == "volume":
            pool = ed.get("source_pool", "default")
            vol = ed.get("source_volume", "")
            lines.append(f"      <source pool='{pool}' volume='{vol}'/>")
        elif ed["type"] == "network":
            proto = ed.get("source_protocol", "iscsi")
            sname = ed.get("source_name", "")
            lines.append(f"      <source protocol='{proto}' name='{sname}'/>")
        target_dev = ed.get("target_dev", "vda")
        target_bus = ed.get("target_bus", "virtio")
        lines.append(f"      <target dev='{target_dev}' bus='{target_bus}'/>")
        if ed.get("readonly"):
            lines.append("      <readonly/>")
        lines.append("    </disk>")

    for nd in new_disks:
        dtype = nd.get("type", "file")
        if dtype == "block":
            lines.append("    <disk type='block' device='disk'>")
            lines.append(f"      <driver name='qemu' type='{nd.get('driver_type', 'raw')}'/>")
            lines.append(f"      <source dev='{nd.get('source_dev', '')}'/>")
            lines.append(f"      <target dev='{nd.get('target_dev', 'vdb')}' bus='{nd.get('target_bus', 'virtio')}'/>")
            lines.append("    </disk>")
        else:
            lines.append("    <disk type='file' device='disk'>")
            lines.append(f"      <driver name='qemu' type='{nd.get('driver_type', 'qcow2')}'/>")
            lines.append(f"      <source file='{nd.get('source_file', '')}'/>")
            lines.append(f"      <target dev='{nd.get('target_dev', 'vdb')}' bus='{nd.get('target_bus', 'virtio')}'/>")
            lines.append("    </disk>")

    iso_idx = 0
    for iso in iso_paths:
        if isinstance(iso, dict):
            iso_path = iso.get("path", "").strip()
            iso_target = iso.get("target", "").strip()
        else:
            iso_path = str(iso).strip()
            iso_target = ""
        if iso_path:
            dev = iso_target if iso_target else f"sd{chr(ord('c') + iso_idx)}"
            lines.append("    <disk type='file' device='cdrom'>")
            lines.append("      <driver name='qemu' type='raw'/>")
            lines.append(f"      <source file='{iso_path}'/>")
            lines.append(f"      <target dev='{dev}' bus='sata'/>")
            lines.append("      <readonly/>")
            lines.append("    </disk>")
            iso_idx += 1

    lines.append(f"    <graphics type='vnc' port='{vnc_port}' autoport='yes' listen='{vnc_listen}'>")
    lines.append(f"      <listen type='address' address='{vnc_listen}'/>")
    lines.append("    </graphics>")

    if spice_enabled:
        spice_attrs = f"    <graphics type='spice' port='{spice_port}' autoport='yes' listen='{spice_listen}'"
        if spice_tls_port:
            spice_attrs += f" tlsPort='{spice_tls_port}'"
        spice_attrs += ">"
        lines.append(spice_attrs)
        lines.append(f"      <listen type='address' address='{spice_listen}'/>")
        lines.append("      <image compression='off'/>")
        lines.append("      <playback compression='on'/>")
        lines.append("      <streaming mode='filter'/>")
        lines.append("      <clipboard copypaste='yes'/>")
        lines.append("      <filetransfer enable='yes'/>")
        lines.append("    </graphics>")

    lines.append(f"    <interface type='{net_type}'>")
    if net_type == "network":
        lines.append(f"      <source network='{net_source}'/>")
    elif net_type == "bridge":
        lines.append(f"      <source bridge='{net_source}'/>")
    elif net_type == "direct":
        lines.append(f"      <source dev='{net_source}'/>")
    lines.append(f"      <model type='{net_model}'/>")
    lines.append("    </interface>")

    for hd in hostdevs:
        lines.append("    <hostdev mode='subsystem' type='pci' managed='yes'>")
        lines.append("      <source>")
        lines.append(f"        <address domain='{hd.get('domain', '0x0000')}' bus='{hd.get('bus', '0x00')}' slot='{hd.get('slot', '0x00')}' function='{hd.get('function', '0x0')}'/>")
        lines.append("      </source>")
        lines.append("    </hostdev>")

    for uhd in existing_usbs:
        lines.append("    <hostdev mode='subsystem' type='usb' managed='yes'>")
        lines.append("      <source>")
        lines.append(f"        <vendor id='{uhd['vendor_id']}'/>")
        lines.append(f"        <product id='{uhd['product_id']}'/>")
        lines.append("      </source>")
        lines.append("    </hostdev>")

    usb_hostdevs = config.get("usb_hostdevs", [])
    for uhd in usb_hostdevs:
        lines.append("    <hostdev mode='subsystem' type='usb' managed='yes'>")
        lines.append("      <source>")
        lines.append(f"        <vendor id='0x{uhd['vendor_id']}'/>")
        lines.append(f"        <product id='0x{uhd['product_id']}'/>")
        lines.append("      </source>")
        lines.append("    </hostdev>")

    lines.append("    <video>")
    if video_model == "qxl":
        lines.append("      <model type='qxl' ram='65536' vram='65536' vgamem='16384' heads='1'/>")
    elif video_model and video_model != "none":
        lines.append(f"      <model type='{video_model}' heads='1'/>")
    elif not video_model:
        lines.append("      <model type='virtio' heads='1'/>")
    lines.append("    </video>")

    if tpm_enabled:
        lines.append("    <tpm model='tpm-crb'>")
        lines.append("      <backend type='emulator'/>")
        lines.append("    </tpm>")

    sound_enabled = config.get("sound_enabled", False)
    channel_spice = config.get("channel_spice", False)
    usb_redirector_1 = config.get("usb_redirector_1", False)
    usb_redirector_2 = config.get("usb_redirector_2", False)

    if sound_enabled:
        lines.append("    <sound model='ich9'/>")

    if channel_spice:
        lines.append("    <channel type='spicevmc'>")
        lines.append("      <target type='virtio' name='com.redhat.spice.0'/>")
        lines.append("    </channel>")

    if usb_redirector_1:
        lines.append("    <redirdev bus='usb' type='spicevmc'/>")
    if usb_redirector_2:
        lines.append("    <redirdev bus='usb' type='spicevmc'/>")

    lines.append("    <memballoon model='virtio'/>")
    lines.append("  </devices>")
    lines.append("  <seclabel type='dynamic' model='apparmor' relabel='yes'/>")
    lines.append("</domain>")

    return "\n".join(lines)


@app.route("/api/vm/<name>/action", methods=["POST"])
def vm_action(name):
    conn = get_conn()
    try:
        dom = conn.lookupByName(name)
    except libvirt.libvirtError:
        conn.close()
        return jsonify({"error": f"VM '{name}' が見つかりません"}), 404

    action = request.json.get("action")
    try:
        if action == "start":
            dom.create()
        elif action == "stop":
            dom.shutdown()
        elif action == "destroy":
            dom.destroy()
        elif action == "undefine":
            if dom.isActive():
                conn.close()
                return jsonify({"error": "先にVMを停止してください"}), 400

            delete_disk = request.json.get("delete_disk", False)
            disk_paths = []
            if delete_disk:
                xml_str = dom.XMLDesc(0)
                root = ET.fromstring(xml_str)
                for disk in root.findall(".//disk"):
                    device = disk.get("device", "disk")
                    if device == "cdrom":
                        continue
                    source = disk.find("source")
                    if source is None:
                        continue
                    path = source.get("file", "") or source.get("dev", "")
                    if not path:
                        pool_name = source.get("pool", "")
                        vol_name = source.get("volume", "")
                        if pool_name and vol_name:
                            try:
                                pool = conn.storagePoolLookupByName(pool_name)
                                vol = pool.storageVolLookupByName(vol_name)
                                path = vol.path()
                            except Exception:
                                pass
                    if path:
                        disk_paths.append(path)

            import subprocess
            try:
                subprocess.run(
                    ["sudo", "virsh", "undefine", name, "--nvram"],
                    capture_output=True, timeout=10, check=True
                )
            except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
                try:
                    dom.undefine()
                except libvirt.libvirtError as ue:
                    conn.close()
                    return jsonify({"error": str(ue)}), 400

            if delete_disk and disk_paths:
                for dp in disk_paths:
                    try:
                        subprocess.run(
                            ["sudo", "rm", "-f", dp],
                            capture_output=True, timeout=10
                        )
                    except Exception:
                        pass
                try:
                    nvram_path = f"/var/lib/libvirt/qemu/nvram/{name}_VARS.fd"
                    subprocess.run(
                        ["sudo", "rm", "-f", nvram_path],
                        capture_output=True, timeout=5
                    )
                except Exception:
                    pass
        elif action == "suspend":
            dom.suspend()
        elif action == "resume":
            dom.resume()
        elif action == "reboot":
            dom.reboot()
        elif action == "autostart_on":
            dom.setAutostart(1)
        elif action == "autostart_off":
            dom.setAutostart(0)
        elif action == "usb_attach":
            vendor_id = request.json.get("vendor_id", "")
            product_id = request.json.get("product_id", "")
            if not vendor_id or not product_id:
                result = {"error": "vendor_id と product_id が必要です"}
            else:
                if dom.isActive():
                    usb_xml = f"""<hostdev mode='subsystem' type='usb' managed='yes'>
      <source>
        <vendor id='0x{vendor_id}'/>
        <product id='0x{product_id}'/>
      </source>
    </hostdev>"""
                    try:
                        import subprocess, tempfile
                        with tempfile.NamedTemporaryFile(mode='w', suffix='.xml', delete=False) as f:
                            f.write(usb_xml)
                            tmp_path = f.name
                        r = subprocess.run(
                            ["sudo", "virsh", "attach-device", name, "--file", tmp_path],
                            capture_output=True, text=True, timeout=10
                        )
                        subprocess.run(["sudo", "rm", "-f", tmp_path], capture_output=True, timeout=5)
                        if r.returncode != 0:
                            result = {"error": r.stderr.strip() or r.stdout.strip()}
                    except (subprocess.TimeoutExpired, Exception) as e:
                        result = {"error": str(e)}
                else:
                    try:
                        xml_str = dom.XMLDesc(0)
                        root = ET.fromstring(xml_str)
                        devices_el = root.find(".//devices")
                        hostdev_el = ET.SubElement(devices_el, "hostdev")
                        hostdev_el.set("mode", "subsystem")
                        hostdev_el.set("type", "usb")
                        hostdev_el.set("managed", "yes")
                        source_el = ET.SubElement(hostdev_el, "source")
                        vendor_el = ET.SubElement(source_el, "vendor")
                        vendor_el.set("id", f"0x{vendor_id}")
                        product_el = ET.SubElement(source_el, "product")
                        product_el.set("id", f"0x{product_id}")
                        new_xml = ET.tostring(root, encoding="unicode")
                        conn.defineXML(new_xml)
                    except libvirt.libvirtError as e:
                        result = {"error": str(e)}
        elif action == "usb_detach":
            vendor_id = request.json.get("vendor_id", "")
            product_id = request.json.get("product_id", "")
            if not vendor_id or not product_id:
                result = {"error": "vendor_id と product_id が必要です"}
            else:
                if dom.isActive():
                    usb_xml = f"""<hostdev mode='subsystem' type='usb' managed='yes'>
      <source>
        <vendor id='0x{vendor_id}'/>
        <product id='0x{product_id}'/>
      </source>
    </hostdev>"""
                    try:
                        import subprocess, tempfile
                        with tempfile.NamedTemporaryFile(mode='w', suffix='.xml', delete=False) as f:
                            f.write(usb_xml)
                            tmp_path = f.name
                        r = subprocess.run(
                            ["sudo", "virsh", "detach-device", name, "--file", tmp_path],
                            capture_output=True, text=True, timeout=10
                        )
                        subprocess.run(["sudo", "rm", "-f", tmp_path], capture_output=True, timeout=5)
                        if r.returncode != 0:
                            result = {"error": r.stderr.strip() or r.stdout.strip()}
                    except (subprocess.TimeoutExpired, Exception) as e:
                        result = {"error": str(e)}
                else:
                    try:
                        xml_str = dom.XMLDesc(0)
                        root = ET.fromstring(xml_str)
                        devices_el = root.find(".//devices")
                        removed = False
                        for hd in root.findall(".//hostdev[@type='usb']"):
                            src = hd.find("source")
                            if src is not None:
                                v = src.find("vendor")
                                p = src.find("product")
                                if v is not None and p is not None:
                                    vid = v.get("id", "").replace("0x", "")
                                    pid = p.get("id", "").replace("0x", "")
                                    if vid == vendor_id and pid == product_id:
                                        devices_el.remove(hd)
                                        removed = True
                                        break
                        if not removed:
                            result = {"error": f"USBデバイス 0x{vendor_id}:0x{product_id} が見つかりません"}
                        else:
                            new_xml = ET.tostring(root, encoding="unicode")
                            conn.defineXML(new_xml)
                    except libvirt.libvirtError as e:
                        result = {"error": str(e)}
        elif action == "disk_attach":
            disk_xml = request.json.get("xml", "")
            if not disk_xml:
                result = {"error": "ディスクXMLが必要です"}
            else:
                try:
                    import subprocess, tempfile
                    with tempfile.NamedTemporaryFile(mode='w', suffix='.xml', delete=False) as f:
                        f.write(disk_xml)
                        tmp_path = f.name
                    if dom.isActive():
                        r = subprocess.run(
                            ["sudo", "virsh", "attach-device", name, "--file", tmp_path, "--live", "--persistent"],
                            capture_output=True, text=True, timeout=10
                        )
                    else:
                        r = subprocess.run(
                            ["sudo", "virsh", "attach-device", name, "--file", tmp_path, "--persistent"],
                            capture_output=True, text=True, timeout=10
                        )
                    subprocess.run(["sudo", "rm", "-f", tmp_path], capture_output=True, timeout=5)
                    if r.returncode != 0:
                        result = {"error": r.stderr.strip() or r.stdout.strip()}
                except (subprocess.TimeoutExpired, Exception) as e:
                    result = {"error": str(e)}
        elif action == "disk_detach":
            target_dev = request.json.get("target_dev", "")
            if not target_dev:
                result = {"error": "ターゲットデバイス名が必要です"}
            else:
                try:
                    xml_str = dom.XMLDesc(0)
                    root = ET.fromstring(xml_str)
                    devices_el = root.find(".//devices")
                    removed = False
                    for disk in root.findall(".//disk"):
                        target = disk.find("target")
                        if target is not None and target.get("dev") == target_dev:
                            devices_el.remove(disk)
                            removed = True
                            break
                    if not removed:
                        result = {"error": f"デバイス '{target_dev}' が見つかりません"}
                    else:
                        new_xml = ET.tostring(root, encoding="unicode")
                        conn.defineXML(new_xml)
                        result = {"success": True}
                except libvirt.libvirtError as e:
                    result = {"error": str(e)}
        elif action == "disk_update_source":
            target_dev = request.json.get("target_dev", "")
            new_source = request.json.get("new_source", "")
            if not target_dev:
                result = {"error": "ターゲットデバイス名が必要です"}
            elif dom.isActive():
                try:
                    import subprocess
                    if new_source:
                        r = subprocess.run(
                            ["sudo", "virsh", "change-media", name, target_dev, "--source", new_source],
                            capture_output=True, text=True, timeout=10
                        )
                    else:
                        r = subprocess.run(
                            ["sudo", "virsh", "change-media", name, target_dev, "--eject"],
                            capture_output=True, text=True, timeout=10
                        )
                    if r.returncode != 0:
                        result = {"error": r.stderr.strip() or r.stdout.strip()}
                    else:
                        result = {"success": True}
                except (subprocess.TimeoutExpired, Exception) as e:
                    result = {"error": str(e)}
            else:
                try:
                    xml_str = dom.XMLDesc(0)
                    root = ET.fromstring(xml_str)
                    updated = False
                    for disk in root.findall(".//disk"):
                        target = disk.find("target")
                        if target is not None and target.get("dev") == target_dev:
                            source = disk.find("source")
                            if new_source:
                                if source is not None:
                                    source.set("file", new_source)
                                else:
                                    source = ET.SubElement(disk, "source")
                                    source.set("file", new_source)
                            else:
                                if source is not None:
                                    disk.remove(source)
                            updated = True
                            break
                    if not updated:
                        result = {"error": f"デバイス '{target_dev}' が見つかりません"}
                    else:
                        new_xml = ET.tostring(root, encoding="unicode")
                        conn.defineXML(new_xml)
                        result = {"success": True}
                except libvirt.libvirtError as e:
                    result = {"error": str(e)}
        elif action == "disk_resize":
            target_dev = request.json.get("target_dev", "")
            new_size = request.json.get("new_size", "")
            if not target_dev or not new_size:
                result = {"error": "ターゲットデバイス名と新しいサイズが必要です"}
            elif dom.isActive():
                result = {"error": "VMを停止してからディスクを拡大してください"}
            else:
                try:
                    xml_str = dom.XMLDesc(0)
                    root = ET.fromstring(xml_str)
                    disk_path = ""
                    for disk in root.findall(".//disk"):
                        target = disk.find("target")
                        if target is not None and target.get("dev") == target_dev:
                            source = disk.find("source")
                            if source is not None:
                                disk_path = source.get("file", "")
                            break
                    if not disk_path:
                        result = {"error": f"デバイス '{target_dev}' のパスが見つかりません"}
                    else:
                        import subprocess
                        r = subprocess.run(
                            ["qemu-img", "info", "--output=json", disk_path],
                            capture_output=True, text=True, timeout=10
                        )
                        if r.returncode != 0:
                            result = {"error": f"ディスク情報の取得に失敗: {r.stderr.strip()}"}
                        else:
                            import json
                            info = json.loads(r.stdout)
                            old_size = info.get("virtual-size", 0)
                            r2 = subprocess.run(
                                ["qemu-img", "resize", disk_path, new_size],
                                capture_output=True, text=True, timeout=30
                            )
                            if r2.returncode != 0:
                                result = {"error": f"リサイズ失敗: {r2.stderr.strip()}"}
                            else:
                                r3 = subprocess.run(
                                    ["qemu-img", "info", "--output=json", disk_path],
                                    capture_output=True, text=True, timeout=10
                                )
                                new_info = json.loads(r3.stdout) if r3.returncode == 0 else {}
                                result = {
                                    "success": True,
                                    "old_size": old_size,
                                    "new_size": new_info.get("virtual-size", 0)
                                }
                except Exception as e:
                    result = {"error": str(e)}
        else:
            conn.close()
            return jsonify({"error": f"不明なアクション: {action}"}), 400
        result = {"success": True}
    except libvirt.libvirtError as e:
        result = {"error": str(e)}
    conn.close()
    return jsonify(result)


@app.route("/vm/create", methods=["GET", "POST"])
def vm_create():
    conn = get_conn()
    storage_pools = []
    for pname in conn.listStoragePools():
        pool = conn.storagePoolLookupByName(pname)
        pool.refresh(0)
        storage_pools.append({
            "name": pname,
            "active": pool.isActive(),
            "type": pool.info()[0],
        })

    networks = []
    for nname in conn.listNetworks():
        net = conn.networkLookupByName(nname)
        networks.append({"name": nname, "active": net.isActive()})

    hostdevs = []
    try:
        for nd in conn.listAllNodeDevices(0):
            try:
                nd_xml = nd.XMLDesc(0)
                nd_root = ET.fromstring(nd_xml)
                driver_el = nd_root.find("driver")
                if driver_el is not None and driver_el.get("name") == "vfio-pci":
                    cap = nd_root.find("capability")
                    vendor_el = cap.find("vendor") if cap is not None else None
                    product_el = cap.find("product") if cap is not None else None
                    hostdevs.append({
                        "name": nd.name(),
                        "vendor_id": vendor_el.get("id", "") if vendor_el is not None else "",
                        "product_id": product_el.get("id", "") if product_el is not None else "",
                        "description": cap.get("id", "") if cap is not None else nd.name(),
                    })
            except Exception:
                continue
    except Exception:
        pass

    conn.close()

    if request.method == "POST":
        config = request.json
        try:
            conn = get_conn()

            disk_size_gb = config.get("disk_size_gb", "")
            disk_pool = config.get("disk_pool", "default")
            vm_name = config.get("name", "").strip()
            disk_path = ""
            if disk_size_gb and vm_name:
                disk_path = _create_volume(conn, vm_name, disk_pool, int(disk_size_gb))
            config["_disk_path"] = disk_path

            xml, errors = _build_vm_xml(config)
            if errors:
                conn.close()
                return jsonify({"error": errors}), 400

            conn.defineXML(xml)

            autostart = config.get("autostart", False)
            if autostart:
                dom = conn.lookupByName(vm_name)
                dom.setAutostart(1)

            conn.close()
            return jsonify({"success": True})
        except libvirt.libvirtError as e:
            return jsonify({"error": str(e)}), 400

    usb_devices = _get_usb_devices()

    return render_template(
        "vm_create.html",
        storage_pools=storage_pools,
        networks=networks,
        hostdevs=hostdevs,
        usb_devices=usb_devices,
    )


def _create_volume(conn, vm_name, pool_name, size_gb):
    try:
        pool = conn.storagePoolLookupByName(pool_name)
    except libvirt.libvirtError:
        return

    vol_name = f"{vm_name}.qcow2"

    vol_xml = f"""
    <volume>
      <name>{vol_name}</name>
      <capacity unit='G'>{size_gb}</capacity>
      <target>
        <format type='qcow2'/>
      </target>
    </volume>"""

    try:
        vol = pool.createXML(vol_xml, 0)
    except libvirt.libvirtError:
        try:
            vol = pool.storageVolLookupByName(vol_name)
        except libvirt.libvirtError:
            return ""

    vol_path = ""
    try:
        vol_path = vol.path()
        import subprocess
        subprocess.run(
            ["sudo", "chown", "libvirt-qemu:kvm", vol_path],
            capture_output=True, timeout=5, check=True
        )
        subprocess.run(
            ["sudo", "chmod", "0644", vol_path],
            capture_output=True, timeout=5, check=True
        )
    except Exception:
        pass

    return vol_path


def _build_vm_xml(config):
    name = config.get("name", "").strip()
    if not name:
        return None, "VM名を入力してください"

    domain_type = config.get("domain_type", "kvm")
    vcpus = int(config.get("vcpus", 2))
    memory_mb = int(config.get("memory_mb", 4096))
    memory_kb = memory_mb * 1024

    arch = config.get("arch", "x86_64")
    machine = config.get("machine", "pc-q35-10.2")

    disk_size_gb = config.get("disk_size_gb", "")
    disk_pool = config.get("disk_pool", "default")
    disk_bus = config.get("disk_bus", "virtio")

    net_type = config.get("net_type", "network")
    net_source = config.get("net_source", "default")
    net_model = config.get("net_model", "virtio")

    vnc_port = config.get("vnc_port", "") or "-1"
    try:
        int(vnc_port)
    except (ValueError, TypeError):
        vnc_port = "-1"
    vnc_listen = config.get("vnc_listen", "") or "0.0.0.0"
    vnc_passwd = config.get("vnc_passwd", "")

    spice_enabled = config.get("spice_enabled", False)
    spice_port = config.get("spice_port", "") or "-1"
    try:
        int(spice_port)
    except (ValueError, TypeError):
        spice_port = "-1"
    spice_tls_port = config.get("spice_tls_port", "") or ""
    spice_listen = config.get("spice_listen", "") or "0.0.0.0"

    video_model = config.get("video_model", "")
    if not video_model:
        video_model = "qxl" if spice_enabled else "virtio"
    tpm_enabled = config.get("tpm_enabled", False)
    sound_enabled = config.get("sound_enabled", False)
    channel_spice = config.get("channel_spice", False)
    usb_redirector_1 = config.get("usb_redirector_1", False)
    usb_redirector_2 = config.get("usb_redirector_2", False)
    boot_order = config.get("boot_order", [])

    disks_config = config.get("disks", [])
    hostdevs = config.get("hostdevs", [])
    existing_usbs = config.get("existing_usbs", [])

    lines = []
    lines.append(f'<domain type="{domain_type}">')
    lines.append(f"  <name>{name}</name>")
    lines.append(f"  <memory unit='KiB'>{memory_kb}</memory>")
    lines.append(f"  <currentMemory unit='KiB'>{memory_kb}</currentMemory>")
    lines.append(f"  <vcpu placement='static'>{vcpus}</vcpu>")
    uefi = config.get("uefi", False)
    secure_boot = config.get("secure_boot", False)
    boot_order = config.get("boot_order", [])
    if uefi:
        if secure_boot:
            lines.append("  <os firmware='efi'>")
            lines.append(f"    <type arch='{arch}' machine='{machine}'>hvm</type>")
            lines.append("    <firmware>")
            lines.append("      <feature enabled='yes' name='enrolled-keys'/>")
            lines.append("      <feature enabled='yes' name='secure-boot'/>")
            lines.append("    </firmware>")
            lines.append("    <loader readonly='yes' secure='yes' type='pflash' format='raw'>/usr/share/OVMF/OVMF_CODE_4M.ms.fd</loader>")
            lines.append(f"    <nvram template='/usr/share/OVMF/OVMF_VARS_4M.ms.fd' templateFormat='raw' format='raw'>/var/lib/libvirt/qemu/nvram/{name}_VARS.fd</nvram>")
        else:
            lines.append("  <os firmware='efi'>")
            lines.append(f"    <type arch='{arch}' machine='{machine}'>hvm</type>")
            lines.append("    <firmware>")
            lines.append("      <feature enabled='no' name='enrolled-keys'/>")
            lines.append("      <feature enabled='no' name='secure-boot'/>")
            lines.append("    </firmware>")
            lines.append("    <loader readonly='yes' secure='no' type='pflash' stateless='yes' format='raw'>/usr/share/ovmf/OVMF.amdsev.fd</loader>")
        if boot_order:
            for dev in boot_order:
                lines.append(f"    <boot dev='{dev}'/>")
        else:
            lines.append("    <boot dev='hd'/>")
        lines.append("    <bootmenu enable='yes'/>")
    else:
        lines.append("  <os>")
        lines.append(f"    <type arch='{arch}' machine='{machine}'>hvm</type>")
        if boot_order:
            for dev in boot_order:
                lines.append(f"    <boot dev='{dev}'/>")
        else:
            lines.append("    <boot dev='hd'/>")
    lines.append("  </os>")
    lines.append("  <features>")
    lines.append("    <acpi/>")
    lines.append("    <apic/>")
    lines.append("  </features>")
    lines.append("  <cpu mode='host-passthrough'/>")
    lines.append("  <clock offset='utc'/>")
    lines.append("  <devices>")

    if disk_size_gb:
        disk_path = config.get("_disk_path", "")
        if disk_path:
            lines.append("    <disk type='file' device='disk'>")
            lines.append("      <driver name='qemu' type='qcow2'/>")
            lines.append(f"      <source file='{disk_path}'/>")
            lines.append(f"      <target dev='vda' bus='{disk_bus}'/>")
            lines.append("    </disk>")
        else:
            lines.append("    <disk type='volume' device='disk'>")
            lines.append("      <driver name='qemu' type='qcow2'/>")
            lines.append(f"      <source pool='{disk_pool}' volume='{name}.qcow2'/>")
            lines.append(f"      <target dev='vda' bus='{disk_bus}'/>")
            lines.append("    </disk>")

    dev_letters = "bcdefghijklmnop"
    dev_idx = 0

    iso_paths = config.get("iso_paths", [])
    iso_idx = 0
    for iso in iso_paths:
        if isinstance(iso, dict):
            iso_path = iso.get("path", "").strip()
            iso_target = iso.get("target", "").strip()
        else:
            iso_path = str(iso).strip()
            iso_target = ""
        if iso_path:
            dev = iso_target if iso_target else f"sd{chr(ord('c') + iso_idx)}"
            lines.append("    <disk type='file' device='cdrom'>")
            lines.append("      <driver name='qemu' type='raw'/>")
            lines.append(f"      <source file='{iso_path}'/>")
            lines.append(f"      <target dev='{dev}' bus='sata'/>")
            lines.append("      <readonly/>")
            lines.append("    </disk>")
            iso_idx += 1

    for dc in disks_config:
        dtype = dc.get("type", "")
        if dtype == "block":
            lines.append("    <disk type='block' device='disk'>")
            driver_type = dc.get("driver_type", "raw")
            lines.append(f"      <driver name='qemu' type='{driver_type}'/>")
            lines.append(f"      <source dev='{dc.get('source_dev', '')}'/>")
            target_dev = dc.get("target_dev", f"vd{dev_letters[dev_idx]}")
            target_bus = dc.get("target_bus", "virtio")
            lines.append(f"      <target dev='{target_dev}' bus='{target_bus}'/>")
            lines.append("    </disk>")
        elif dtype == "file":
            lines.append("    <disk type='file' device='disk'>")
            driver_type = dc.get("driver_type", "qcow2")
            lines.append(f"      <driver name='qemu' type='{driver_type}'/>")
            lines.append(f"      <source file='{dc.get('source_file', '')}'/>")
            target_dev = dc.get("target_dev", f"vd{dev_letters[dev_idx]}")
            target_bus = dc.get("target_bus", "virtio")
            lines.append(f"      <target dev='{target_dev}' bus='{target_bus}'/>")
            lines.append("    </disk>")
        dev_idx += 1

    lines.append(f"    <graphics type='vnc' port='{vnc_port}' autoport='yes' listen='{vnc_listen}'>")
    lines.append(f"      <listen type='address' address='{vnc_listen}'/>")
    lines.append("    </graphics>")

    if spice_enabled:
        spice_attrs = f"    <graphics type='spice' port='{spice_port}' autoport='yes' listen='{spice_listen}'"
        if spice_tls_port:
            spice_attrs += f" tlsPort='{spice_tls_port}'"
        spice_attrs += ">"
        lines.append(spice_attrs)
        lines.append(f"      <listen type='address' address='{spice_listen}'/>")
        lines.append("      <image compression='off'/>")
        lines.append("      <playback compression='on'/>")
        lines.append("      <streaming mode='filter'/>")
        lines.append("      <clipboard copypaste='yes'/>")
        lines.append("      <filetransfer enable='yes'/>")
        lines.append("    </graphics>")

    lines.append(f"    <interface type='{net_type}'>")
    if net_type == "network":
        lines.append(f"      <source network='{net_source}'/>")
    elif net_type == "bridge":
        lines.append(f"      <source bridge='{net_source}'/>")
    elif net_type == "direct":
        lines.append(f"      <source dev='{net_source}'/>")
    lines.append(f"      <model type='{net_model}'/>")
    lines.append("    </interface>")

    for hd in hostdevs:
        hd_domain = hd.get("domain", "0x0000")
        hd_bus = hd.get("bus", "0x00")
        hd_slot = hd.get("slot", "0x00")
        hd_function = hd.get("function", "0x0")
        lines.append("    <hostdev mode='subsystem' type='pci' managed='yes'>")
        lines.append("      <source>")
        lines.append(f"        <address domain='{hd_domain}' bus='{hd_bus}' slot='{hd_slot}' function='{hd_function}'/>")
        lines.append("      </source>")
        lines.append("    </hostdev>")

    for uhd in existing_usbs:
        lines.append("    <hostdev mode='subsystem' type='usb' managed='yes'>")
        lines.append("      <source>")
        lines.append(f"        <vendor id='{uhd['vendor_id']}'/>")
        lines.append(f"        <product id='{uhd['product_id']}'/>")
        lines.append("      </source>")
        lines.append("    </hostdev>")

    usb_hostdevs = config.get("usb_hostdevs", [])
    for uhd in usb_hostdevs:
        lines.append("    <hostdev mode='subsystem' type='usb' managed='yes'>")
        lines.append("      <source>")
        lines.append(f"        <vendor id='0x{uhd['vendor_id']}'/>")
        lines.append(f"        <product id='0x{uhd['product_id']}'/>")
        lines.append("      </source>")
        lines.append("    </hostdev>")

    lines.append("    <video>")
    if video_model == "qxl":
        lines.append("      <model type='qxl' ram='65536' vram='65536' vgamem='16384' heads='1'/>")
    else:
        lines.append("      <model type='virtio' heads='1'/>")
    lines.append("    </video>")

    if tpm_enabled:
        lines.append("    <tpm model='tpm-crb'>")
        lines.append("      <backend type='emulator'/>")
        lines.append("    </tpm>")

    if sound_enabled:
        lines.append("    <sound model='ich9'/>")

    if channel_spice:
        lines.append("    <channel type='spicevmc'>")
        lines.append("      <target type='virtio' name='com.redhat.spice.0'/>")
        lines.append("    </channel>")

    if usb_redirector_1:
        lines.append("    <redirdev bus='usb' type='spicevmc'/>")
    if usb_redirector_2:
        lines.append("    <redirdev bus='usb' type='spicevmc'/>")

    lines.append("    <memballoon model='virtio'/>")
    lines.append("  </devices>")
    lines.append("  <seclabel type='dynamic' model='apparmor' relabel='yes'/>")
    lines.append("</domain>")

    return "\n".join(lines), None


@app.route("/api/vm/<name>/xml", methods=["GET", "PUT"])
def vm_xml(name):
    conn = get_conn()
    try:
        dom = conn.lookupByName(name)
    except libvirt.libvirtError:
        conn.close()
        return jsonify({"error": f"VM '{name}' が見つかりません"}), 404

    if request.method == "GET":
        xml_str = dom.XMLDesc(0)
        conn.close()
        return jsonify({"xml": xml_str})
    else:
        new_xml = request.json.get("xml", "")
        try:
            conn.defineXML(new_xml)
            conn.close()
            return jsonify({"success": True})
        except libvirt.libvirtError as e:
            conn.close()
            return jsonify({"error": str(e)}), 400


@app.route("/api/vm/<name>/bootorder", methods=["GET", "PUT"])
def vm_bootorder(name):
    conn = get_conn()
    try:
        dom = conn.lookupByName(name)
    except libvirt.libvirtError:
        conn.close()
        return jsonify({"error": f"VM '{name}' が見つかりません"}), 404

    if dom.isActive():
        conn.close()
        return jsonify({"error": "VMを停止してからブート順序を変更してください"}), 400

    if request.method == "GET":
        xml_str = dom.XMLDesc(0)
        root = ET.fromstring(xml_str)
        boot_order = []
        idx = 1
        for os_boot in root.findall(".//os/boot"):
            dev = os_boot.get("dev", "")
            if dev:
                boot_order.append({"dev": dev, "order": idx})
                idx += 1
        conn.close()
        return jsonify({"boot_order": boot_order})
    else:
        boot_devs = request.json.get("boot_order", [])
        xml_str = dom.XMLDesc(0)
        root = ET.fromstring(xml_str)

        for os_boot in root.findall(".//os/boot"):
            root.find(".//os").remove(os_boot)

        os_el = root.find(".//os")
        for bd in boot_devs:
            dev = bd.get("dev", "")
            if dev:
                boot_el = ET.SubElement(os_el, "boot")
                boot_el.set("dev", dev)

        new_xml = ET.tostring(root, encoding="unicode")
        try:
            conn.defineXML(new_xml)
            conn.close()
            return jsonify({"success": True})
        except libvirt.libvirtError as e:
            conn.close()
            return jsonify({"error": str(e)}), 400


@app.route("/api/storage")
def api_storage():
    conn = get_conn()
    pools = []
    for pname in conn.listStoragePools():
        pool = conn.storagePoolLookupByName(pname)
        pool.refresh(0)
        info = pool.info()
        volumes = []
        for vol_name in pool.listVolumes():
            vol = pool.storageVolLookupByName(vol_name)
            vol_info = vol.info()
            volumes.append({
                "name": vol_name,
                "capacity_mb": vol_info[1] // (1024 * 1024),
                "allocation_mb": vol_info[2] // (1024 * 1024),
            })
        pools.append({
            "name": pname,
            "active": pool.isActive(),
            "type": info[0],
            "capacity_mb": info[1] // (1024 * 1024),
            "allocation_mb": info[2] // (1024 * 1024),
            "volumes": volumes,
        })
    conn.close()
    return jsonify(pools)


@app.route("/api/block-devices")
def api_block_devices():
    import subprocess
    devices = []
    try:
        result = subprocess.run(
            ["lsblk", "-J", "-o", "NAME,SIZE,TYPE,MOUNTPOINT,MODEL"],
            capture_output=True, text=True, timeout=5
        )
        if result.returncode == 0:
            import json
            data = json.loads(result.stdout)
            for dev in data.get("blockdevices", []):
                name = dev.get("name", "")
                dev_type = dev.get("type", "")
                size = dev.get("size", "")
                model = (dev.get("model") or "").strip()
                mountpoint = dev.get("mountpoint") or ""
                if dev_type in ("disk", "part", "lvm"):
                    label = f"/dev/{name}"
                    if model:
                        label += f" ({model})"
                    label += f" - {size}"
                    if mountpoint:
                        label += f" [{mountpoint}]"
                    devices.append({
                        "path": f"/dev/{name}",
                        "name": name,
                        "size": size,
                        "type": dev_type,
                        "model": model,
                        "mountpoint": mountpoint,
                        "label": label,
                    })
                for child in dev.get("children", []):
                    cname = child.get("name", "")
                    ctype = child.get("type", "")
                    csize = child.get("size", "")
                    cmodel = (child.get("model") or "").strip()
                    cmountpoint = child.get("mountpoint") or ""
                    if ctype in ("part", "lvm"):
                        clabel = f"/dev/{cname}"
                        if cmodel:
                            clabel += f" ({cmodel})"
                        clabel += f" - {csize}"
                        if cmountpoint:
                            clabel += f" [{cmountpoint}]"
                        devices.append({
                            "path": f"/dev/{cname}",
                            "name": cname,
                            "size": csize,
                            "type": ctype,
                            "model": cmodel,
                            "mountpoint": cmountpoint,
                            "label": clabel,
                        })
    except Exception:
        pass
    return jsonify(devices)


@app.route("/api/storage-pool-volumes")
def api_storage_pool_volumes():
    conn = get_conn()
    volumes = []
    for pname in conn.listStoragePools():
        try:
            pool = conn.storagePoolLookupByName(pname)
            pool.refresh(0)
            for vol_name in pool.listVolumes():
                vol = pool.storageVolLookupByName(vol_name)
                vol_info = vol.info()
                vol_path = vol.path()
                size_mb = vol_info[1] // (1024 * 1024)
                volumes.append({
                    "path": vol_path,
                    "name": vol_name,
                    "pool": pname,
                    "size_mb": size_mb,
                    "label": f"[{pname}] {vol_name} ({size_mb} MB)",
                })
        except Exception:
            continue
    conn.close()
    return jsonify(volumes)


@app.route("/api/iso-files")
def api_iso_files():
    conn = get_conn()
    isos = []
    iso_exts = ('.iso', '.img', '.raw', '.qcow2', '.vmdk', '.vhdx', '.vdi')
    for pname in conn.listStoragePools():
        try:
            pool = conn.storagePoolLookupByName(pname)
            pool.refresh(0)
            for vol_name in pool.listVolumes():
                if any(vol_name.lower().endswith(ext) for ext in iso_exts):
                    vol = pool.storageVolLookupByName(vol_name)
                    vol_info = vol.info()
                    isos.append({
                        "name": vol_name,
                        "path": vol.path(),
                        "pool": pname,
                        "size_mb": vol_info[1] // (1024 * 1024),
                        "label": f"[{pname}] {vol_name} ({vol_info[1] // (1024 * 1024)} MB)",
                    })
        except Exception:
            continue
    conn.close()
    return jsonify(isos)


@app.route("/api/networks")
def api_networks():
    conn = get_conn()
    networks = []
    for nname in conn.listNetworks():
        net = conn.networkLookupByName(nname)
        networks.append({
            "name": nname,
            "active": net.isActive(),
            "autostart": net.autostart(),
            "bridge": net.bridgeName() if net.bridgeName() else "",
        })
    conn.close()
    return jsonify(networks)


_websockify_procs = {}

import subprocess as _sp
try:
    _sp.run(["pkill", "-9", "-f", "websockify"], capture_output=True, timeout=5)
except Exception:
    pass


@app.route("/api/vm/<name>/snapshots")
def api_snapshots(name):
    conn = get_conn()
    try:
        dom = conn.lookupByName(name)
    except libvirt.libvirtError:
        conn.close()
        return jsonify({"error": f"VM '{name}' が見つかりません"}), 404

    snapshots = []
    try:
        for snap in dom.listAllSnapshots(0):
            xml_str = snap.getXMLDesc(0)
            root = ET.fromstring(xml_str)
            creation = root.findtext("creationTime", "0")
            state = root.findtext("state", "unknown")
            sname = root.findtext("name", "")
            desc = root.findtext("description", "")
            snapshots.append({
                "name": sname,
                "state": state,
                "creation": int(creation) if creation else 0,
                "description": desc,
            })
    except libvirt.libvirtError:
        pass
    conn.close()
    snapshots.sort(key=lambda s: s["creation"], reverse=True)
    return jsonify(snapshots)


@app.route("/api/vm/<name>/snapshot-create", methods=["POST"])
def api_snapshot_create(name):
    conn = get_conn()
    try:
        dom = conn.lookupByName(name)
    except libvirt.libvirtError:
        conn.close()
        return jsonify({"error": f"VM '{name}' が見つかりません"}), 404

    config = request.json or {}
    snap_name = config.get("name", "").strip()
    snap_desc = config.get("description", "").strip()

    if not snap_name:
        import datetime
        snap_name = datetime.datetime.now().strftime("snap-%Y%m%d-%H%M%S")

    snap_xml = f"""<domainsnapshot>
    <name>{snap_name}</name>
    <description>{snap_desc}</description>
</domainsnapshot>"""

    try:
        flags = libvirt.VIR_DOMAIN_SNAPSHOT_CREATE_ATOMIC
        dom.snapshotCreateXML(snap_xml, flags)
        conn.close()
        return jsonify({"success": True})
    except libvirt.libvirtError as e:
        conn.close()
        return jsonify({"error": str(e)}), 400


@app.route("/api/vm/<name>/snapshot-delete", methods=["POST"])
def api_snapshot_delete(name):
    conn = get_conn()
    try:
        dom = conn.lookupByName(name)
    except libvirt.libvirtError:
        conn.close()
        return jsonify({"error": f"VM '{name}' が見つかりません"}), 404

    snap_name = (request.json or {}).get("name", "")
    if not snap_name:
        conn.close()
        return jsonify({"error": "スナップショット名が必要です"}), 400

    try:
        snap = dom.snapshotLookupByName(snap_name, 0)
        snap.delete(0)
        conn.close()
        return jsonify({"success": True})
    except libvirt.libvirtError as e:
        conn.close()
        return jsonify({"error": str(e)}), 400


@app.route("/api/vm/<name>/snapshot-revert", methods=["POST"])
def api_snapshot_revert(name):
    conn = get_conn()
    try:
        dom = conn.lookupByName(name)
    except libvirt.libvirtError:
        conn.close()
        return jsonify({"error": f"VM '{name}' が見つかりません"}), 404

    if dom.isActive():
        conn.close()
        return jsonify({"error": "VMを停止してから元に戻してください"}), 400

    snap_name = (request.json or {}).get("name", "")
    if not snap_name:
        conn.close()
        return jsonify({"error": "スナップショット名が必要です"}), 400

    try:
        snap = dom.snapshotLookupByName(snap_name, 0)
        flags = libvirt.VIR_DOMAIN_SNAPSHOT_REVERT_RUNNING
        dom.revertToSnapshot(snap, flags)
        conn.close()
        return jsonify({"success": True})
    except libvirt.libvirtError as e:
        conn.close()
        return jsonify({"error": str(e)}), 400


@app.route("/api/vm/<name>/vnc-info")
def api_vnc_info(name):
    conn = get_conn()
    try:
        dom = conn.lookupByName(name)
    except libvirt.libvirtError:
        conn.close()
        return jsonify({"error": f"VM '{name}' が見つかりません"}), 404

    xml_str = dom.XMLDesc(0)
    root = ET.fromstring(xml_str)
    vnc_port = None
    vnc_listen = "127.0.0.1"
    for graphics in root.findall(".//graphics"):
        if graphics.get("type") == "vnc":
            vnc_port = graphics.get("port", "")
            vnc_listen = graphics.get("listen", "127.0.0.1")
            listen_el = graphics.find("listen")
            if listen_el is not None:
                vnc_listen = listen_el.get("address", vnc_listen)
            break
    is_active = dom.isActive()
    conn.close()
    if not vnc_port:
        return jsonify({"error": "VNCが有効ではありません"}), 400
    return jsonify({"port": vnc_port, "listen": vnc_listen, "active": is_active})


@app.route("/api/vm/<name>/status")
def api_vm_status(name):
    conn = get_conn()
    try:
        dom = conn.lookupByName(name)
    except libvirt.libvirtError:
        conn.close()
        return jsonify({"error": f"VM '{name}' が見つかりません"}), 404
    is_active = dom.isActive()
    conn.close()
    return jsonify({"active": is_active})


@app.route("/api/vm/<name>/console-proxy", methods=["POST", "DELETE"])
def console_proxy(name):
    import subprocess, signal

    if request.method == "DELETE":
        proc = _websockify_procs.pop(name, None)
        if proc:
            if proc.poll() is None:
                proc.kill()
                try:
                    proc.wait(timeout=3)
                except Exception:
                    pass
        try:
            subprocess.run(
                ["pkill", "-9", "-f", "websockify"],
                capture_output=True, timeout=5
            )
        except Exception:
            pass
        return jsonify({"success": True})

    conn = get_conn()
    try:
        dom = conn.lookupByName(name)
    except libvirt.libvirtError:
        conn.close()
        return jsonify({"error": f"VM '{name}' が見つかりません"}), 404

    xml_str = dom.XMLDesc(0)
    root = ET.fromstring(xml_str)
    vnc_port = None
    for graphics in root.findall(".//graphics"):
        if graphics.get("type") == "vnc":
            vnc_port = graphics.get("port", "")
            break
    conn.close()

    if not vnc_port:
        return jsonify({"error": "VNCが有効ではありません"}), 400

    proc = _websockify_procs.get(name)
    if proc and proc.poll() is None:
        ws_port = proc.ws_port if hasattr(proc, 'ws_port') else None
        if ws_port:
            try:
                import urllib.request
                urllib.request.urlopen(f"http://127.0.0.1:{ws_port}/", timeout=2)
                return jsonify({"ws_port": ws_port})
            except Exception:
                proc.kill()
                proc.wait(timeout=3)
    _websockify_procs.pop(name, None)

    try:
        import subprocess as sp
        sp.run(["pkill", "-9", "-f", "websockify"], capture_output=True, timeout=5)
        import time as _time
        _time.sleep(0.3)

        import socket
        s = socket.socket()
        s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        s.bind(("", 0))
        ws_port = s.getsockname()[1]
        s.close()

        proc = subprocess.Popen(
            ["websockify", "--web", "/usr/share/novnc/", str(ws_port), f"127.0.0.1:{vnc_port}"],
            stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
        )
        proc.ws_port = ws_port
        _websockify_procs[name] = proc
        return jsonify({"ws_port": ws_port})
    except FileNotFoundError:
        return jsonify({"error": "websockifyがインストールされていません。 sudo apt install novnc python3-websockify"}), 500
    except Exception as e:
        return jsonify({"error": str(e)}), 500


@app.route("/vm/<name>/console")
def vm_console(name):
    return render_template("vm_console.html", vm_name=name)


@app.route("/novnc/<path:filename>")
def novnc_static(filename):
    from flask import send_from_directory
    return send_from_directory("/usr/share/novnc", filename)


if __name__ == "__main__":
    app.run(host="0.0.0.0", port=8090, debug=True)
ENDOFFILE_APP_PY

cat << 'ENDOFFILE_BASE.HTML' > /opt/vm-manage/templates/base.html
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{% block title %}VM Manager{% endblock %}</title>
    <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css">
</head>
<body>
    <nav class="sidebar">
        <div class="sidebar-header">
            <i class="fas fa-server"></i>
            <span>VM Manager</span>
        </div>
        <ul class="sidebar-menu">
            <li><a href="/vm/create" class="{% if request.endpoint == 'vm_create' %}active{% endif %}"><i class="fas fa-plus-circle"></i> 新規作成</a></li>
        </ul>
        <div style="padding:8px 16px;border-top:1px solid var(--border)">
            <div style="font-size:0.75em;color:var(--text-secondary);margin-bottom:6px">仮想マシン</div>
            {% for vm in sidebar_vms %}
            <a href="/vm/{{ vm.name }}" style="display:flex;align-items:center;gap:8px;padding:6px 0;color:var(--text-primary);text-decoration:none;font-size:0.9em;{% if request.path == '/vm/' + vm.name %}color:var(--accent){% endif %}">
                <span style="width:8px;height:8px;border-radius:50%;flex-shrink:0;background:{% if vm.state == 'running' %}#2ecc71{% else %}#666{% endif %}"></span>
                <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ vm.name }}</span>
            </a>
            {% endfor %}
            {% if not sidebar_vms %}
            <div style="font-size:0.85em;color:var(--text-secondary)">VMなし</div>
            {% endif %}
        </div>
    </nav>
    <main class="content">
        {% with messages = get_flashed_messages(with_categories=true) %}
            {% if messages %}
                {% for category, message in messages %}
                    <div class="alert alert-{{ category }}">{{ message }}</div>
                {% endfor %}
            {% endif %}
        {% endwith %}
        {% block content %}{% endblock %}
    </main>
    <script src="{{ url_for('static', filename='js/app.js') }}"></script>
    {% block scripts %}{% endblock %}
</body>
</html>
ENDOFFILE_BASE.HTML

cat << 'ENDOFFILE_INDEX.HTML' > /opt/vm-manage/templates/index.html
{% extends "base.html" %}
{% block title %}VM一覧 - VM Manager{% endblock %}
{% block content %}
<h1><i class="fas fa-list"></i> 仮想マシン一覧</h1>
<div class="card-header">
    <span>全{{ vms|length }}台</span>
    <a href="/vm/create" class="btn btn-primary"><i class="fas fa-plus"></i> 新規作成</a>
</div>
{% if vms %}
<div class="grid grid-2">
    {% for vm in vms %}
    <div class="vm-card">
        <div class="vm-card-header">
            <h3><a href="/vm/{{ vm.name }}" style="color:inherit;text-decoration:none">{{ vm.name }}</a></h3>
            <span class="status-badge status-{{ vm.state }}">{{ vm.state }}</span>
        </div>
        <dl class="vm-info">
            <dt>仮想化</dt><dd>{{ vm.domain_type|upper }}</dd>
            <dt>マシン</dt><dd>{{ vm.machine }}</dd>
            <dt>CPU</dt><dd>{{ vm.vcpus }} vCPU</dd>
            <dt>メモリ</dt><dd>{{ vm.memory_mb }} MB</dd>
        </dl>
        <div class="btn-group">
            {% if vm.state == 'stopped' %}
                <button class="btn btn-success btn-sm" onclick="vmAction('{{ vm.name }}', 'start')"><i class="fas fa-play"></i> 起動</button>
            {% else %}
                <button class="btn btn-warning btn-sm" onclick="vmAction('{{ vm.name }}', 'stop')"><i class="fas fa-stop"></i> 停止</button>
                <button class="btn btn-danger btn-sm" onclick="vmAction('{{ vm.name }}', 'destroy')"><i class="fas fa-power-off"></i> 強制停止</button>
                <button class="btn btn-info btn-sm" onclick="vmAction('{{ vm.name }}', 'reboot')"><i class="fas fa-redo"></i> 再起動</button>
            {% endif %}
            <a href="/vm/{{ vm.name }}" class="btn btn-info btn-sm"><i class="fas fa-edit"></i> 編集</a>
        </div>
    </div>
    {% endfor %}
</div>
{% else %}
<div class="card" style="text-align:center;padding:60px">
    <i class="fas fa-desktop" style="font-size:3em;color:var(--text-secondary);margin-bottom:15px"></i>
    <p style="color:var(--text-secondary)">仮想マシンがありません</p>
    <a href="/vm/create" class="btn btn-primary" style="margin-top:15px"><i class="fas fa-plus"></i> 最初のVMを作成</a>
</div>
{% endif %}
{% endblock %}
{% block scripts %}
<script>
function vmAction(name, action) {
    if (action === 'undefine') {
        if (!confirm('このVMを削除しますか?')) return;
        const deleteDisk = confirm('ディスクファイルも削除しますか?');
        fetch(`/api/vm/${name}/action`, {
            method: 'POST',
            headers: {'Content-Type': 'application/json'},
            body: JSON.stringify({action: action, delete_disk: deleteDisk})
        })
        .then(r => r.json())
        .then(data => {
            if (data.error) alert('エラー: ' + data.error);
            else location.reload();
        })
        .catch(err => alert('通信エラー: ' + err));
        return;
    }

    const msgs = {destroy: '強制停止しますか?', stop: 'シャットダウンしますか?'};
    if (msgs[action] && !confirm(msgs[action])) return;
    fetch(`/api/vm/${name}/action`, {
        method: 'POST',
        headers: {'Content-Type': 'application/json'},
        body: JSON.stringify({action: action})
    })
    .then(r => r.json())
    .then(data => {
        if (data.error) alert('エラー: ' + data.error);
        else location.reload();
    })
    .catch(err => alert('通信エラー: ' + err));
}
</script>
{% endblock %}
ENDOFFILE_INDEX.HTML

cat << 'ENDOFFILE_VM_CONSOLE.HTML' > /opt/vm-manage/templates/vm_console.html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ vm_name }} - Console</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #1a1a2e; color: #eee; font-family: -apple-system, sans-serif; overflow: hidden; height: 100vh; display: flex; flex-direction: column; }
.toolbar {
    display: flex; align-items: center; gap: 10px; padding: 8px 16px;
    background: #16213e; border-bottom: 1px solid #333; z-index: 10;
}
.toolbar h1 { font-size: 1em; font-weight: 500; flex: 1; }
.toolbar .status { font-size: 0.85em; padding: 3px 10px; border-radius: 4px; }
.toolbar .status.connected { background: #27ae60; color: #fff; }
.toolbar .status.connecting { background: #f39c12; color: #fff; }
.toolbar .status.error { background: #e74c3c; color: #fff; }
.toolbar button {
    background: #0f3460; color: #eee; border: 1px solid #555;
    padding: 5px 12px; border-radius: 4px; cursor: pointer; font-size: 0.85em;
}
.toolbar button:hover { background: #1a508b; }
#console-container { flex: 1; display: flex; align-items: center; justify-content: center; position: relative; background: #000; }
#noVNC_canvas { width: 100%; aspect-ratio: 16 / 10; max-height: 100%; }
.loading { text-align: center; color: #888; }
.loading .spinner { display: inline-block; width: 40px; height: 40px; border: 3px solid #333; border-top-color: #3498db; border-radius: 50%; animation: spin 1s linear infinite; margin-bottom: 15px; }
@keyframes spin { to { transform: rotate(360deg); } }
</style>
</head>
<body>
<div class="toolbar">
    <h1>{{ vm_name }} - VNC Console</h1>
    <span id="status" class="status connecting">接続中...</span>
    <button id="btn-cad" disabled>Ctrl+Alt+Del</button>
    <button id="btn-paste" disabled>クリップボード</button>
    <button onclick="doDisconnect(); window.close();">閉じる</button>
</div>
<div id="console-container">
    <div class="loading" id="loading">
        <div class="spinner"></div>
        <div>VNCに接続中...</div>
    </div>
</div>

<script type="module">
import RFB from '/novnc/core/rfb.js';

const vmName = "{{ vm_name }}";
const statusEl = document.getElementById('status');
const loading = document.getElementById('loading');
const btnCAD = document.getElementById('btn-cad');
const btnPaste = document.getElementById('btn-paste');
let rfb = null;

function setStatus(text, cls) {
    statusEl.textContent = text;
    statusEl.className = 'status ' + cls;
}

async function connect() {
    try {
        setStatus('プロキシ起動中...', 'connecting');
        const resp = await fetch(`/api/vm/${vmName}/console-proxy`, { method: 'POST' });
        const data = await resp.json();
        if (data.error) { setStatus('エラー: ' + data.error, 'error'); return; }

        setStatus('接続中...', 'connecting');
        const wsUrl = `ws://${location.hostname}:${data.ws_port}`;

        const container = document.getElementById('console-container');
        const canvas = document.createElement('canvas');
        canvas.id = 'noVNC_canvas';
        container.appendChild(canvas);

        rfb = new RFB(container, wsUrl, {
            credentials: {},
            wsProtocols: ['binary'],
        });

        rfb.addEventListener('connected', () => {
            setStatus('接続済み', 'connected');
            loading.style.display = 'none';
            btnCAD.disabled = false;
            btnPaste.disabled = false;
        });
        rfb.addEventListener('disconnected', () => {
            setStatus('切断', 'error');
            btnCAD.disabled = true;
            btnPaste.disabled = true;
        });
        rfb.addEventListener('failed', () => {
            setStatus('接続失敗', 'error');
        });

        rfb.scaleViewport = true;
        rfb.resizeSession = true;
    } catch (e) {
        setStatus('エラー: ' + e.message, 'error');
    }
}

function doDisconnect() {
    if (rfb) { rfb.disconnect(); rfb = null; }
    fetch(`/api/vm/${vmName}/console-proxy`, { method: 'DELETE' }).catch(() => {});
}

btnCAD.addEventListener('click', () => { if (rfb) rfb.sendCtrlAltDel(); });
btnPaste.addEventListener('click', async () => {
    const text = prompt('貼り付けテキスト:');
    if (text && rfb) rfb.clipboardPasteFrom(text);
});

window.addEventListener('beforeunload', () => { doDisconnect(); });
connect();
</script>
</body>
</html>
ENDOFFILE_VM_CONSOLE.HTML

cat << 'ENDOFFILE_VM_CREATE.HTML' > /opt/vm-manage/templates/vm_create.html
{% extends "base.html" %}
{% block title %}新規作成 - VM Manager{% endblock %}
{% block content %}
<h1><i class="fas fa-plus-circle"></i> 仮想マシン新規作成</h1>
<p style="color:var(--text-secondary);margin-bottom:20px">VNC/SPICE同時有効化、ブロックデバイスパススルー、PCI passthroughなどが可能です</p>

<form id="vm-form">

<div class="card">
    <h2><i class="fas fa-cog"></i> 基本設定</h2>
    <div class="form-group">
        <label>VM名 *</label>
        <input type="text" name="name" required placeholder="例: win11, ubuntu2604">
    </div>
    <div class="form-row">
        <div class="form-group">
            <label>仮想化タイプ</label>
            <select name="domain_type">
                <option value="kvm">KVM(推奨)</option>
                <option value="qemu">QEMU(エミュレーション)</option>
            </select>
        </div>
        <div class="form-group">
            <label>アーキテクチャ</label>
            <select name="arch">
                <option value="x86_64">x86_64</option>
                <option value="aarch64">aarch64</option>
            </select>
        </div>
    </div>
    <div class="form-row">
        <div class="form-group">
            <label>マシンタイプ</label>
            <select name="machine">
                <option value="pc-q35-10.2" selected>pc-q35-10.2 (Q35)</option>
                <option value="pc-q35-9.1">pc-q35-9.1</option>
                <option value="pc-q35-9.0">pc-q35-9.0</option>
                <option value="pc-i440fx-9.1">pc-i440fx-9.1</option>
                <option value="virt">virt (ARM)</option>
            </select>
        </div>
        <div class="form-group">
            <label>vCPU数</label>
            <select name="vcpus">
                {% for n in [1,2,4,6,8,12,16,24,32,48,64] %}
                <option value="{{ n }}" {% if n == 2 %}selected{% endif %}>{{ n }}</option>
                {% endfor %}
            </select>
        </div>
    </div>
    <div class="form-group">
        <label>メモリ (MB)</label>
        <select name="memory_mb">
            {% for mb, label in [(512,'512 MB'),(1024,'1 GB'),(2048,'2 GB'),(3072,'3 GB'),(4096,'4 GB'),(6144,'6 GB'),(8192,'8 GB'),(12288,'12 GB'),(16384,'16 GB'),(24576,'24 GB'),(32768,'32 GB'),(49152,'48 GB'),(65536,'64 GB')] %}
            <option value="{{ mb }}" {% if mb == 4096 %}selected{% endif %}>{{ label }}</option>
            {% endfor %}
        </select>
    </div>
    <div class="checkbox-group">
        <input type="checkbox" name="uefi" id="uefi" checked onchange="document.getElementById('secureboot-group').style.display=this.checked?'flex':'none'">
        <label for="uefi">UEFIブート (OVMF) を有効にする</label>
        <small style="margin-left:5px;color:var(--text-secondary)">Windows 11等で必須</small>
    </div>
    <div class="checkbox-group" id="secureboot-group" style="display:flex">
        <input type="checkbox" name="secure_boot" id="secure_boot">
        <label for="secure_boot">Secure Boot を有効にする</label>
        <small style="margin-left:5px;color:var(--text-secondary)">Windows 11等で推奨。CachyOS等は非推奨</small>
    </div>
    <div class="checkbox-group">
        <input type="checkbox" name="tpm_enabled" id="tpm_enabled">
        <label for="tpm_enabled">TPM v2.0 を有効にする</label>
        <small style="margin-left:5px;color:var(--text-secondary)">Windows 11等で推奨</small>
    </div>
</div>

<div class="card">
    <div class="card-header">
        <h2><i class="fas fa-hdd"></i> ディスク</h2>
    </div>
    <div class="form-row">
        <div class="form-group">
            <label>プライマリディスク (GB) - 0で作成しない</label>
            <select name="disk_size_gb">
                <option value="0">作成しない</option>
                {% for gb in [10,20,30,40,50,60,80,100,120,150,200,256,300,400,500,750,1024] %}
                <option value="{{ gb }}" {% if gb == 50 %}selected{% endif %}>{{ gb }} GB</option>
                {% endfor %}
            </select>
        </div>
        <div class="form-group">
            <label>ストレージプール</label>
            <select name="disk_pool">
                {% for pool in storage_pools %}
                <option value="{{ pool.name }}" {% if pool.name == 'default' %}selected{% endif %}>{{ pool.name }}{% if pool.active %} ✓{% endif %}</option>
                {% endfor %}
            </select>
        </div>
    </div>
    <div class="form-group">
        <label>ディスクバス</label>
        <select name="disk_bus">
            <option value="virtio">virtio (推奨)</option>
            <option value="sata">SATA</option>
            <option value="scsi">SCSI</option>
        </select>
    </div>

    <h3 style="margin-top:15px">追加ディスク</h3>
    <div id="extra-disks"></div>
    <div id="extra-disk-config" class="disk-item" style="margin-top:5px">
        <div class="form-row-3">
            <div class="form-group">
                <label>タイプ</label>
                <select id="cfg-extra-type" onchange="onCfgTypeChange()">
                    <option value="file">ファイル (qcow2/raw)</option>
                    <option value="block">ブロックデバイス</option>
                </select>
            </div>
            <div class="form-group">
                <label>ソースパス</label>
                <div style="display:flex;gap:4px">
                    <select id="cfg-extra-source-select" style="flex:2" onchange="onCfgSourceSelect()">
                        <option value="">-- デバイスを選択 --</option>
                    </select>
                    <input type="text" id="cfg-extra-source" style="flex:1" placeholder="/path/to/file">
                </div>
            </div>
            <div class="form-group">
                <label>ターゲット</label>
                <input type="text" id="cfg-extra-target" value="vdb">
            </div>
        </div>
        <div class="form-row" style="align-items:end">
            <div class="form-group">
                <label>バス</label>
                <select id="cfg-extra-bus">
                    <option value="virtio">virtio</option>
                    <option value="sata">SATA</option>
                    <option value="scsi">SCSI</option>
                </select>
            </div>
            <div class="form-group">
                <label>ドライバー</label>
                <select id="cfg-extra-driver">
                    <option value="qcow2">qcow2</option>
                    <option value="raw">raw</option>
                </select>
            </div>
            <div class="form-group" style="margin:0">
                <button type="button" class="btn btn-primary btn-sm" onclick="addExtraDisk()"><i class="fas fa-plus"></i> 追加</button>
            </div>
        </div>
    </div>

    <h3 style="margin-top:15px"><i class="fas fa-compact-disc"></i> ISO / CD-ROM</h3>
    <div id="iso-disks"></div>
    <div style="display:flex;gap:8px;align-items:end;margin-top:8px">
        <div class="form-group" style="flex:1;margin:0">
            <label>プールから選択</label>
            <select id="iso-pool-select">
                <option value="">-- 選択してください --</option>
            </select>
        </div>
        <div class="form-group" style="margin:0">
            <label>ターゲット</label>
            <input type="text" id="iso-cfg-target" value="sdc" style="width:60px">
        </div>
        <button type="button" class="btn btn-primary btn-sm" onclick="addIsoFromPool()" style="margin-bottom:2px"><i class="fas fa-plus"></i> 追加</button>
    </div>
    <div style="display:flex;gap:8px;align-items:end;margin-top:8px">
        <div class="form-group" style="flex:1;margin:0">
            <label>手動入力</label>
            <input type="text" id="iso-cfg-path" placeholder="/path/to/file.iso">
        </div>
        <div class="form-group" style="margin:0">
            <label>ターゲット</label>
            <input type="text" id="iso-cfg-target-manual" value="sdc" style="width:60px">
        </div>
        <button type="button" class="btn btn-secondary btn-sm" onclick="addIsoManual()" style="margin-bottom:2px"><i class="fas fa-plus"></i> 追加</button>
    </div>
</div>

<div class="card">
    <div class="card-header">
        <h2><i class="fas fa-sort-amount-up"></i> ブート順序</h2>
    </div>
    <p style="color:var(--text-secondary);margin-bottom:10px;font-size:0.9em">ブートデバイスの優先順位を設定します(上から順に試行されます)</p>
    <div id="boot-order-list">
        <div class="disk-item" style="display:flex;align-items:center;gap:8px">
            <span style="color:var(--text-secondary)">1.</span>
            <select name="boot_dev_1" class="boot-dev-select" onchange="updateBootOrderNumbers()">
                <option value="hd" selected>ハードディスク (hd)</option>
                <option value="cdrom">CD-ROM (cdrom)</option>
                <option value="network">ネットワーク (network)</option>
                <option value="usb">USB</option>
            </select>
            <button type="button" class="remove-btn" onclick="this.parentElement.remove();updateBootOrderNumbers()"><i class="fas fa-times"></i></button>
        </div>
    </div>
    <button type="button" class="btn btn-primary btn-sm" onclick="addBootDev()" style="margin-top:8px"><i class="fas fa-plus"></i> ブートデバイス追加</button>
</div>

<div class="card">
    <div class="card-header">
        <h2><i class="fas fa-network-wired"></i> ネットワーク</h2>
    </div>
    <div class="form-row">
        <div class="form-group">
            <label>ネットワークタイプ</label>
            <select name="net_type">
                <option value="network">Libvirt仮想ネットワーク</option>
                <option value="bridge">ブリッジ</option>
                <option value="direct">ダイレクト</option>
            </select>
        </div>
        <div class="form-group">
            <label>ネットワークソース</label>
            <select name="net_source">
                {% for net in networks %}
                <option value="{{ net.name }}" {% if net.name == 'default' %}selected{% endif %}>{{ net.name }}{% if net.active %} ✓{% endif %}</option>
                {% endfor %}
            </select>
        </div>
    </div>
    <div class="form-group">
        <label>ネットワークモデル</label>
        <select name="net_model">
            <option value="virtio">virtio (推奨)</option>
            <option value="e1000">E1000</option>
            <option value="e1000e">E1000e</option>
            <option value="rtl8139">RTL8139</option>
        </select>
    </div>
</div>

<div class="card">
    <div class="card-header">
        <h2><i class="fas fa-tv"></i> グラフィックス</h2>
    </div>
    <p style="color:var(--text-secondary);margin-bottom:12px;font-size:0.9em">VNCとSPICEを<strong>両方</strong>有効にできます</p>

    <h3>VNC(常時有効)</h3>
    <div class="form-row">
        <div class="form-group">
            <label>ポート(-1で自動)</label>
            <input type="text" name="vnc_port" value="-1">
        </div>
        <div class="form-group">
            <label>リッスンアドレス</label>
            <input type="text" name="vnc_listen" value="0.0.0.0">
        </div>
    </div>
    <div class="form-group">
        <label>VNCパスワード(空なら認証なし)</label>
        <input type="password" name="vnc_passwd" placeholder="空なら認証なし">
    </div>

    <h3 style="margin-top:15px">SPICE</h3>
    <div class="checkbox-group">
        <input type="checkbox" name="spice_enabled" id="spice_enabled" checked>
        <label for="spice_enabled">SPICEを有効にする</label>
    </div>
    <div id="spice-options">
        <div class="form-row">
            <div class="form-group">
                <label>ポート(-1で自動)</label>
                <input type="text" name="spice_port" value="-1">
            </div>
            <div class="form-group">
                <label>TLSポート(空ならTLS無効)</label>
                <input type="text" name="spice_tls_port" placeholder="空なら無効">
            </div>
        </div>
        <div class="form-group">
            <label>リッスンアドレス</label>
            <input type="text" name="spice_listen" value="0.0.0.0">
        </div>
    </div>
</div>

<div class="card">
    <h2><i class="fas fa-tv"></i> ビデオ</h2>
    <div class="form-row">
        <div class="form-group">
            <label>ビデオモデル</label>
            <select name="video_model">
                <option value="">自動(SPICEならQXL、VNCならVirtIO)</option>
                <option value="virtio">VirtIO GPU(推奨・VNC向け)</option>
                <option value="qxl">QXL(推奨・SPICE向け)</option>
                <option value="cirrus">cirrus(レガシー)</option>
                <option value="none">デバイスなし</option>
            </select>
        </div>
    </div>
</div>

<div class="card">
    <div class="card-header">
        <h2><i class="fas fa-volume-up"></i> サウンド / チャンネル / USBリダイレクター</h2>
    </div>
    <div class="checkbox-group">
        <input type="checkbox" name="sound_enabled" id="sound_enabled" checked>
        <label for="sound_enabled">サウンドデバイス (ich9)</label>
        <small style="margin-left:5px;color:var(--text-secondary)">SPICEオーディオに使用</small>
    </div>
    <div class="checkbox-group">
        <input type="checkbox" name="channel_spice" id="channel_spice" checked>
        <label for="channel_spice">SPICE Agent チャンネル</label>
        <small style="margin-left:5px;color:var(--text-secondary)">クリップボード共有・モニタ自動調整に必要</small>
    </div>
    <div class="checkbox-group">
        <input type="checkbox" name="usb_redirector_1" id="usb_redirector_1">
        <label for="usb_redirector_1">USB リダイレクター 1</label>
        <small style="margin-left:5px;color:var(--text-secondary)">SPICE経由でホストUSBを転送</small>
    </div>
    <div class="checkbox-group">
        <input type="checkbox" name="usb_redirector_2" id="usb_redirector_2">
        <label for="usb_redirector_2">USB リダイレクター 2</label>
        <small style="margin-left:5px;color:var(--text-secondary)">2台目のUSB転送用</small>
    </div>
</div>

<div class="card">
    <div class="card-header">
        <h2><i class="fas fa-plug"></i> PCIデバイスパススルー</h2>
    </div>
    {% if hostdevs %}
    <p style="color:var(--text-secondary);margin-bottom:10px;font-size:0.9em">VFIOに設定されたデバイスを選択:</p>
    {% for hd in hostdevs %}
    <div class="checkbox-group">
        <input type="checkbox" name="hostdev_{{ loop.index }}" value='{{ {"domain":"0x0000","bus":"0x00","slot":"0x00","function":"0x0"} | tojson }}' data-hd='{{ hd | tojson }}'>
        <label>{{ hd.name }} (Vendor: {{ hd.vendor_id }}, Product: {{ hd.product_id }}) - {{ hd.description }}</label>
    </div>
    {% endfor %}
    {% else %}
    <p style="color:var(--text-secondary);font-size:0.9em"><i class="fas fa-info-circle"></i> VFIOデバイスが見つかりません。GPU等のパススルーにはIOMMUとVFIOの設定が必要です。</p>
    {% endif %}
</div>

<div class="card">
    <div class="card-header">
        <h2><i class="fas fa-usb"></i> USBデバイスパースルー</h2>
    </div>
    <p style="color:var(--text-secondary);margin-bottom:10px;font-size:0.9em">ホストに接続されているUSBデバイスを選択してパースルーします(Ventoy等のUSBブートに使用)</p>
    <div id="usb-devices-list">
        {% for usb in usb_devices %}
        <div class="checkbox-group">
            <input type="checkbox" name="usb_device" value='{{ {"vendor_id": usb.vendor_id, "product_id": usb.product_id} | tojson }}'>
            <label>{{ usb.label }}</label>
        </div>
        {% endfor %}
        {% if not usb_devices %}
        <p style="color:var(--text-secondary)">USBデバイスが検出されませんでした</p>
        {% endif %}
    </div>
</div>

<div class="card">
    <div class="card-header">
        <h2><i class="fas fa-code"></i> 生成されるXMLプレビュー</h2>
    </div>
    <pre id="xml-preview" style="background:var(--bg-dark);padding:15px;border-radius:6px;font-size:0.8em;overflow:auto;max-height:300px;color:var(--text-secondary)">フォーム入力後にここにXMLが表示されます</pre>
</div>

<div style="text-align:right;margin-top:20px;padding-bottom:30px">
    <button type="button" class="btn btn-secondary" onclick="previewXml()" style="margin-right:10px"><i class="fas fa-eye"></i> XMLプレビュー</button>
    <button type="submit" class="btn btn-primary" style="font-size:1em;padding:12px 30px"><i class="fas fa-save"></i> 仮想マシンを作成</button>
</div>

</form>
{% endblock %}

{% block scripts %}
<script>
let extraDiskCount = 0;
let isoDiskCount = 0;
let bootDevCount = 1;
let blockDevices = [];
let poolVolumes = [];

document.getElementById('spice_enabled').addEventListener('change', function() {
    document.getElementById('spice-options').style.display = this.checked ? 'block' : 'none';
});

function addBootDev() {
    bootDevCount++;
    const existingIsos = [];
    document.querySelectorAll('[name^="iso_path_"]').forEach(inp => {
        if (inp.value && inp.value.trim()) existingIsos.push(inp.value.trim());
    });
    const hasUsb = document.querySelectorAll('[name="usb_device"]:checked').length > 0 ||
                   document.getElementById('usb_redirector_1').checked ||
                   document.getElementById('usb_redirector_2').checked;

    let options = '<option value="hd">ハードディスク (hd)</option>';
    if (existingIsos.length > 0) {
        options += '<option value="cdrom">CD-ROM (cdrom)</option>';
    }
    options += '<option value="network">ネットワーク (network)</option>';
    if (hasUsb) {
        options += '<option value="usb">USB</option>';
    }

    const html = `
    <div class="disk-item" style="display:flex;align-items:center;gap:8px">
        <span class="boot-num" style="color:var(--text-secondary)">${bootDevCount}.</span>
        <select name="boot_dev_${bootDevCount}" class="boot-dev-select" onchange="updateBootOrderNumbers()">
            ${options}
        </select>
        <button type="button" class="remove-btn" onclick="this.parentElement.remove();updateBootOrderNumbers()"><i class="fas fa-times"></i></button>
    </div>`;
    document.getElementById('boot-order-list').insertAdjacentHTML('beforeend', html);
}

function updateBootOrderNumbers() {
    const items = document.querySelectorAll('#boot-order-list .disk-item');
    items.forEach((item, idx) => {
        const num = item.querySelector('.boot-num');
        if (num) num.textContent = (idx + 1) + '.';
    });
}

function addExtraDisk() {
    extraDiskCount++;
    const i = extraDiskCount;
    const type = document.getElementById('cfg-extra-type').value;
    const src = document.getElementById('cfg-extra-source').value.trim();
    const target = document.getElementById('cfg-extra-target').value.trim() || 'vdb';
    const bus = document.getElementById('cfg-extra-bus').value;
    const driver = document.getElementById('cfg-extra-driver').value;

    if (!src) { alert('ソースパスを入力してください'); extraDiskCount--; return; }

    const typeLabel = type === 'file' ? 'ファイル' : 'ブロック';
    const html = `
    <div class="disk-item" id="extra-disk-${i}">
        <button type="button" class="remove-btn" onclick="this.parentElement.remove()"><i class="fas fa-times"></i></button>
        <input type="hidden" name="extra_type_${i}" value="${type}">
        <input type="hidden" name="extra_source_${i}" value="${src}">
        <input type="hidden" name="extra_target_${i}" value="${target}">
        <input type="hidden" name="extra_bus_${i}" value="${bus}">
        <input type="hidden" name="extra_driver_${i}" value="${driver}">
        <div style="display:flex;align-items:center;gap:10px">
            <span class="status-badge status-running">${typeLabel}</span>
            <span style="flex:1;overflow:hidden;text-overflow:ellipsis">${src}</span>
            <span style="color:var(--text-secondary)">${target}</span>
            <span style="color:var(--text-secondary)">${bus}</span>
            <span style="color:var(--text-secondary)">${driver}</span>
        </div>
    </div>`;
    document.getElementById('extra-disks').insertAdjacentHTML('beforeend', html);

    document.getElementById('cfg-extra-source').value = '';
}

function onCfgTypeChange() {
    const type = document.getElementById('cfg-extra-type').value;
    const srcSelect = document.getElementById('cfg-extra-source-select');
    const srcInput = document.getElementById('cfg-extra-source');
    if (type === 'file') {
        srcSelect.style.display = 'none';
        srcInput.style.display = '';
        srcInput.placeholder = '/path/to/disk.qcow2';
    } else {
        srcSelect.style.display = '';
        srcInput.style.display = '';
        srcInput.placeholder = '/dev/sdb';
    }
}

function onCfgSourceSelect() {
    const sel = document.getElementById('cfg-extra-source-select');
    const inp = document.getElementById('cfg-extra-source');
    if (sel.value) inp.value = sel.value;
}

function getNextIsoTarget() {
    const used = new Set();
    document.querySelectorAll('[name^="iso_target_"]').forEach(inp => used.add(inp.value));
    document.querySelectorAll('[name^="iso_path_"]').forEach(() => {});
    for (let code = 'sdc'.charCodeAt(2); code <= 'sdz'.charCodeAt(0); code++) {
        const dev = 'sd' + String.fromCharCode(code);
        if (!used.has(dev)) return dev;
    }
    return 'sdc';
}

function addIsoFromPool() {
    const sel = document.getElementById('iso-pool-select');
    if (!sel || !sel.value) { alert('ISOファイルを選択してください'); return; }
    isoDiskCount++;
    const target = document.getElementById('iso-cfg-target').value.trim() || getNextIsoTarget();
    const html = `
    <div class="disk-item" id="iso-disk-${isoDiskCount}">
        <button type="button" class="remove-btn" onclick="this.parentElement.remove()"><i class="fas fa-times"></i></button>
        <input type="hidden" name="iso_path_${isoDiskCount}" value="${sel.value}">
        <div style="display:flex;align-items:center;gap:10px">
            <i class="fas fa-compact-disc" style="color:var(--text-secondary)"></i>
            <span style="flex:1;overflow:hidden;text-overflow:ellipsis">${sel.value.split('/').pop()}</span>
            <input type="text" name="iso_target_${isoDiskCount}" value="${target}" style="width:60px;text-align:center">
        </div>
    </div>`;
    document.getElementById('iso-disks').insertAdjacentHTML('beforeend', html);
    sel.value = '';
    document.getElementById('iso-cfg-target').value = getNextIsoTarget();
}

function addIsoManual() {
    const pathInput = document.getElementById('iso-cfg-path');
    const path = pathInput.value.trim();
    if (!path) { alert('ISOファイルパスを入力してください'); return; }
    isoDiskCount++;
    const target = document.getElementById('iso-cfg-target-manual').value.trim() || getNextIsoTarget();
    const html = `
    <div class="disk-item" id="iso-disk-${isoDiskCount}">
        <button type="button" class="remove-btn" onclick="this.parentElement.remove()"><i class="fas fa-times"></i></button>
        <input type="hidden" name="iso_path_${isoDiskCount}" value="${path}">
        <div style="display:flex;align-items:center;gap:10px">
            <i class="fas fa-compact-disc" style="color:var(--text-secondary)"></i>
            <span style="flex:1;overflow:hidden;text-overflow:ellipsis">${path.split('/').pop()}</span>
            <input type="text" name="iso_target_${isoDiskCount}" value="${target}" style="width:60px;text-align:center">
        </div>
    </div>`;
    document.getElementById('iso-disks').insertAdjacentHTML('beforeend', html);
    pathInput.value = '';
    document.getElementById('iso-cfg-target-manual').value = getNextIsoTarget();
}

function loadIsoFiles() {
    const sel = document.getElementById('iso-pool-select');
    if (!sel) return;
    fetch('/api/iso-files').then(r=>r.json()).then(isos => {
        isos.sort((a,b) => a.name.localeCompare(b.name));
        isos.forEach(iso => {
            const opt = document.createElement('option');
            opt.value = iso.path;
            opt.textContent = iso.label;
            sel.appendChild(opt);
        });
    });
}

function collectFormData() {
    const fd = new FormData(document.getElementById('vm-form'));
    const data = {
        name: fd.get('name'),
        domain_type: fd.get('domain_type'),
        arch: fd.get('arch'),
        machine: fd.get('machine'),
        vcpus: fd.get('vcpus'),
        memory_mb: fd.get('memory_mb'),
        uefi: fd.has('uefi'),
        secure_boot: fd.has('secure_boot'),
        disk_size_gb: fd.get('disk_size_gb'),
        disk_pool: fd.get('disk_pool'),
        disk_bus: fd.get('disk_bus'),
        net_type: fd.get('net_type'),
        net_source: fd.get('net_source'),
        net_model: fd.get('net_model'),
        vnc_port: fd.get('vnc_port'),
        vnc_listen: fd.get('vnc_listen'),
        vnc_passwd: fd.get('vnc_passwd'),
        spice_enabled: fd.has('spice_enabled'),
        spice_port: fd.get('spice_port'),
        spice_tls_port: fd.get('spice_tls_port'),
        spice_listen: fd.get('spice_listen'),
        video_model: fd.get('video_model'),
        tpm_enabled: fd.has('tpm_enabled'),
        sound_enabled: fd.has('sound_enabled'),
        channel_spice: fd.has('channel_spice'),
        usb_redirector_1: fd.has('usb_redirector_1'),
        usb_redirector_2: fd.has('usb_redirector_2'),
        boot_order: [],
        disks: [],
        iso_paths: [],
        hostdevs: [],
        usb_hostdevs: []
    };

    document.querySelectorAll('#boot-order-list .boot-dev-select').forEach(sel => {
        if (sel.value) data.boot_order.push(sel.value);
    });

    for (let i = 1; i <= 50; i++) {
        const src = fd.get('extra_source_' + i);
        if (src) {
            const edtype = fd.get('extra_type_' + i);
            data.disks.push({
                type: edtype,
                source_file: edtype === 'file' ? src : '',
                source_dev: edtype === 'block' ? src : '',
                target_dev: fd.get('extra_target_' + i),
                target_bus: fd.get('extra_bus_' + i),
                driver_type: fd.get('extra_driver_' + i)
            });
        }
    }

    for (let i = 1; i <= 50; i++) {
        const src = fd.get('iso_path_' + i);
        if (src && src.trim()) {
            const target = fd.get('iso_target_' + i) || '';
            data.iso_paths.push({path: src.trim(), target: target});
        }
    }

    document.querySelectorAll('[name^="hostdev_"]:checked').forEach(cb => {
        try {
            const hd = JSON.parse(cb.dataset.hd);
            data.hostdevs.push({
                domain: "0x0000",
                bus: "0x00",
                slot: "0x00",
                function: "0x0"
            });
        } catch(e) {}
    });

    document.querySelectorAll('[name="usb_device"]:checked').forEach(cb => {
        try {
            data.usb_hostdevs.push(JSON.parse(cb.value));
        } catch(e) {}
    });

    return data;
}

function buildXmlFromData(data) {
    let xml = `<domain type="${data.domain_type}">\n`;
    xml += `  <name>${data.name}</name>\n`;
    xml += `  <memory unit='KiB'>${parseInt(data.memory_mb) * 1024}</memory>\n`;
    xml += `  <currentMemory unit='KiB'>${parseInt(data.memory_mb) * 1024}</currentMemory>\n`;
    xml += `  <vcpu placement='static'>${data.vcpus}</vcpu>\n`;
    if (data.uefi) {
        xml += `  <os firmware='efi'>\n`;
        xml += `    <type arch='${data.arch}' machine='${data.machine}'>hvm</type>\n`;
        xml += `    <firmware>\n`;
        xml += `      <feature enabled='yes' name='enrolled-keys'/>\n`;
        xml += `      <feature enabled='yes' name='secure-boot'/>\n`;
        xml += `    </firmware>\n`;
        xml += `    <loader readonly='yes' secure='yes' type='pflash' format='raw'>/usr/share/OVMF/OVMF_CODE_4M.ms.fd</loader>\n`;
        xml += `    <nvram template='/usr/share/OVMF/OVMF_VARS_4M.ms.fd' templateFormat='raw' format='raw'>/var/lib/libvirt/qemu/nvram/${data.name}_VARS.fd</nvram>\n`;
        if (data.boot_order && data.boot_order.length > 0) {
            data.boot_order.forEach(dev => { xml += `    <boot dev='${dev}'/>\n`; });
        } else {
            xml += `    <boot dev='hd'/>\n`;
        }
        xml += `    <bootmenu enable='yes'/>\n`;
    } else {
        xml += `  <os>\n`;
        xml += `    <type arch='${data.arch}' machine='${data.machine}'>hvm</type>\n`;
        if (data.boot_order && data.boot_order.length > 0) {
            data.boot_order.forEach(dev => { xml += `    <boot dev='${dev}'/>\n`; });
        } else {
            xml += `    <boot dev='hd'/>\n`;
        }
    }
    xml += `  </os>\n`;
    xml += `  <features><acpi/><apic/></features>\n`;
    xml += `  <cpu mode='host-passthrough'/>\n`;
    xml += `  <clock offset='utc'/>\n`;
    xml += `  <devices>\n`;

    if (data.disk_size_gb) {
        xml += `    <disk type='volume' device='disk'>\n`;
        xml += `      <driver name='qemu' type='qcow2'/>\n`;
        xml += `      <source pool='${data.disk_pool}' volume='${data.name}.qcow2'/>\n`;
        xml += `      <target dev='vda' bus='${data.disk_bus}'/>\n`;
        xml += `    </disk>\n`;
    }

    const devLetters = 'bcdefghijklmnop';
    let devIdx = 0;
    data.disks.forEach(dc => {
        if (dc.type === 'block') {
            xml += `    <disk type='block' device='disk'>\n`;
            xml += `      <driver name='qemu' type='${dc.driver_type}'/>\n`;
            xml += `      <source dev='${dc.source_dev}'/>\n`;
            xml += `      <target dev='${dc.target_dev}' bus='${dc.target_bus}'/>\n`;
            xml += `    </disk>\n`;
        } else {
            xml += `    <disk type='file' device='disk'>\n`;
            xml += `      <driver name='qemu' type='${dc.driver_type}'/>\n`;
            xml += `      <source file='${dc.source_file}'/>\n`;
            xml += `      <target dev='${dc.target_dev}' bus='${dc.target_bus}'/>\n`;
            xml += `    </disk>\n`;
        }
        devIdx++;
    });

    let isoIdx = 0;
    data.iso_paths.forEach(iso => {
        const path = typeof iso === 'object' ? iso.path : iso;
        const target = (typeof iso === 'object' && iso.target) ? iso.target : String.fromCharCode(99 + isoIdx);
        xml += `    <disk type='file' device='cdrom'>\n`;
        xml += `      <driver name='qemu' type='raw'/>\n`;
        xml += `      <source file='${path}'/>\n`;
        xml += `      <target dev='${target}' bus='sata'/>\n`;
        xml += `      <readonly/>\n`;
        xml += `    </disk>\n`;
        isoIdx++;
    });

    xml += `    <graphics type='vnc' port='${data.vnc_port}' autoport='yes' listen='${data.vnc_listen}'>\n`;
    xml += `      <listen type='address' address='${data.vnc_listen}'/>\n`;
    xml += `    </graphics>\n`;

    if (data.spice_enabled) {
        xml += `    <graphics type='spice' port='${data.spice_port}' autoport='yes' listen='${data.spice_listen}'>\n`;
        xml += `      <listen type='address' address='${data.spice_listen}'/>\n`;
        xml += `      <image compression='off'/>\n`;
        xml += `      <playback compression='on'/>\n`;
        xml += `      <streaming mode='filter'/>\n`;
        xml += `      <clipboard copypaste='yes'/>\n`;
        xml += `      <filetransfer enable='yes'/>\n`;
        xml += `    </graphics>\n`;
    }

    if (data.net_type === 'network') {
        xml += `    <interface type='network'>\n`;
        xml += `      <source network='${data.net_source}'/>\n`;
    } else if (data.net_type === 'bridge') {
        xml += `    <interface type='bridge'>\n`;
        xml += `      <source bridge='${data.net_source}'/>\n`;
    } else {
        xml += `    <interface type='direct'>\n`;
        xml += `      <source dev='${data.net_source}'/>\n`;
    }
    xml += `      <model type='${data.net_model}'/>\n`;
    xml += `    </interface>\n`;

    data.hostdevs.forEach(hd => {
        xml += `    <hostdev mode='subsystem' type='pci' managed='yes'>\n`;
        xml += `      <source>\n`;
        xml += `        <address domain='${hd.domain}' bus='${hd.bus}' slot='${hd.slot}' function='${hd.function}'/>\n`;
        xml += `      </source>\n`;
        xml += `    </hostdev>\n`;
    });

    let videoModel = data.video_model;
    if (!videoModel) {
        videoModel = data.spice_enabled ? 'qxl' : 'virtio';
    }
    if (videoModel !== 'none') {
        xml += `    <video>\n`;
        if (videoModel === 'qxl') {
            xml += `      <model type='qxl' ram='65536' vram='65536' vgamem='16384' heads='1'/>\n`;
        } else {
            xml += `      <model type='${videoModel}' heads='1'/>\n`;
        }
        xml += `    </video>\n`;
    }

    if (data.tpm_enabled) {
        xml += `    <tpm model='tpm-crb'>\n`;
        xml += `      <backend type='emulator'/>\n`;
        xml += `    </tpm>\n`;
    }

    if (data.sound_enabled) {
        xml += `    <sound model='ich9'/>\n`;
    }

    if (data.channel_spice) {
        xml += `    <channel type='spicevmc'>\n`;
        xml += `      <target type='virtio' name='com.redhat.spice.0'/>\n`;
        xml += `    </channel>\n`;
    }

    if (data.usb_redirector_1) {
        xml += `    <redirdev bus='usb' type='spicevmc'/>\n`;
    }
    if (data.usb_redirector_2) {
        xml += `    <redirdev bus='usb' type='spicevmc'/>\n`;
    }

    xml += `    <memballoon model='virtio'/>\n`;
    xml += `  </devices>\n`;
    xml += `  <seclabel type='dynamic' model='apparmor' relabel='yes'/>\n`;
    xml += `</domain>`;
    return xml;
}

function previewXml() {
    const data = collectFormData();
    if (!data.name) {
        alert('VM名を入力してください');
        return;
    }
    document.getElementById('xml-preview').textContent = buildXmlFromData(data);
    document.getElementById('xml-preview').scrollIntoView({behavior: 'smooth'});
}

document.getElementById('vm-form').addEventListener('submit', function(e) {
    e.preventDefault();
    const data = collectFormData();
    if (!data.name) {
        alert('VM名を入力してください');
        return;
    }

    if (!confirm(`VM「${data.name}」を作成しますか?`)) return;

    fetch('/vm/create', {
        method: 'POST',
        headers: {'Content-Type': 'application/json'},
        body: JSON.stringify(data)
    })
    .then(r => r.json())
    .then(result => {
        if (result.error) {
            alert('エラー: ' + result.error);
        } else {
            alert('VM「' + data.name + '」を作成しました');
            window.location.href = '/vm/' + data.name;
        }
    })
    .catch(err => alert('通信エラー: ' + err));
});

document.addEventListener('DOMContentLoaded', function() {
    loadIsoFiles();
    loadBlockDevicesAndPoolVolumes();
    onCfgTypeChange();
});

function loadBlockDevicesAndPoolVolumes() {
    Promise.all([
        fetch('/api/block-devices').then(r => r.json()),
        fetch('/api/storage-pool-volumes').then(r => r.json())
    ]).then(([devices, volumes]) => {
        blockDevices = devices || [];
        poolVolumes = volumes || [];
        const sel = document.getElementById('cfg-extra-source-select');
        if (!sel) return;
        blockDevices.forEach(d => {
            const opt = document.createElement('option');
            opt.value = d.path;
            opt.textContent = d.label;
            sel.appendChild(opt);
        });
        poolVolumes.forEach(v => {
            const opt = document.createElement('option');
            opt.value = v.path;
            opt.textContent = v.label;
            sel.appendChild(opt);
        });
    }).catch(() => {
        blockDevices = [];
        poolVolumes = [];
    });
}
</script>
{% endblock %}
ENDOFFILE_VM_CREATE.HTML

cat << 'ENDOFFILE_VM_DETAIL.HTML' > /opt/vm-manage/templates/vm_detail.html
{% extends "base.html" %}
{% block title %}{{ vm.name }} - VM Manager{% endblock %}
{% block content %}
<h1>
    <i class="fas fa-desktop"></i> {{ vm.name }}
    <span class="status-badge status-{{ vm.state }}" style="margin-left:10px">{{ vm.state }}</span>
    <span class="status-badge" style="background:rgba(52,152,219,0.2);color:var(--info);margin-left:5px">{{ vm.domain_type|upper }}</span>
</h1>

<div class="btn-group" style="margin-bottom:20px">
    {% if vm.state == 'stopped' %}
        <button class="btn btn-success" onclick="vmAction('start')"><i class="fas fa-play"></i> 起動</button>
    {% else %}
        <button class="btn btn-warning" onclick="vmAction('stop')"><i class="fas fa-stop"></i> 停止</button>
        <button class="btn btn-danger" onclick="vmAction('destroy')"><i class="fas fa-power-off"></i> 強制停止</button>
        <button class="btn btn-info" onclick="vmAction('reboot')"><i class="fas fa-redo"></i> 再起動</button>
        <button class="btn btn-secondary" onclick="vmAction('suspend')"><i class="fas fa-pause"></i> 一時停止</button>
    {% endif %}
    <button class="btn btn-secondary" onclick="vmAction('resume')"><i class="fas fa-play-circle"></i> 再開</button>
    <button class="btn btn-danger btn-sm" onclick="vmAction('undefine')"><i class="fas fa-trash"></i> 削除</button>
</div>

{% if is_active %}
<div style="margin-bottom:20px">
    <div class="card" style="padding:0;overflow:hidden">
        <div id="vnc-console-header" style="display:flex;align-items:center;gap:8px;padding:8px 12px;background:var(--bg-dark);border-bottom:1px solid var(--border)">
            <i class="fas fa-terminal" style="color:var(--text-secondary)"></i>
            <span style="flex:1;font-size:0.9em">VNC Console</span>
            <span id="vnc-status" style="font-size:0.8em;padding:2px 8px;border-radius:3px;background:#27ae60;color:#fff">接続済み</span>
            <button type="button" id="vnc-btn-fullscreen" style="background:none;border:1px solid #555;color:#ccc;padding:2px 8px;border-radius:3px;cursor:pointer;font-size:0.8em">全体</button>
            <button type="button" id="vnc-btn-refresh" onclick="cleanupRfb();connectVnc();" style="background:#e94560;border:none;color:#fff;padding:4px 12px;border-radius:4px;cursor:pointer;font-size:0.85em;font-weight:bold"><i class="fas fa-sync-alt"></i> 再接続</button>
        </div>
        <div id="vnc-container" style="width:80%;aspect-ratio:4/3;background:#000;display:flex;align-items:center;justify-content:center;position:relative">
        </div>
    </div>
</div>
{% endif %}

<div class="card">
    <div class="card-header">
        <h2><i class="fas fa-camera"></i> スナップショット</h2>
        <button class="btn btn-primary btn-sm" onclick="createSnapshot()"><i class="fas fa-plus"></i> 作成</button>
    </div>
    <div id="snapshot-list">
        <p style="color:var(--text-secondary)">読み込み中...</p>
    </div>
</div>

<form id="vm-form">
<div class="card">
    <h2><i class="fas fa-cog"></i> 基本設定</h2>
    <div class="form-row">
        <div class="form-group">
            <label>仮想化タイプ</label>
            <select name="domain_type" {% if is_active %}disabled{% endif %}>
                <option value="kvm" {% if os_info.domain_type == 'kvm' %}selected{% endif %}>KVM</option>
                <option value="qemu" {% if os_info.domain_type == 'qemu' %}selected{% endif %}>QEMU</option>
            </select>
        </div>
        <div class="form-group">
            <label>マシンタイプ</label>
            <select name="machine" {% if is_active %}disabled{% endif %}>
                {% for m in ['pc-q35-10.2','pc-q35-9.1','pc-q35-9.0','pc-i440fx-9.1','virt'] %}
                <option value="{{ m }}" {% if os_info.machine == m %}selected{% endif %}>{{ m }}</option>
                {% endfor %}
            </select>
        </div>
    </div>
    <div class="form-row">
        <div class="form-group">
            <label>vCPU数</label>
            <select name="vcpus" {% if is_active %}disabled{% endif %}>
                {% for n in [1,2,4,6,8,12,16,24,32,48,64] %}
                <option value="{{ n }}" {% if vm.vcpus|int == n %}selected{% endif %}>{{ n }}</option>
                {% endfor %}
            </select>
        </div>
        <div class="form-group">
            <label>メモリ (MB)</label>
            <select name="memory_mb" {% if is_active %}disabled{% endif %}>
                {% for mb, label in [(512,'512 MB'),(1024,'1 GB'),(2048,'2 GB'),(3072,'3 GB'),(4096,'4 GB'),(6144,'6 GB'),(8192,'8 GB'),(12288,'12 GB'),(16384,'16 GB'),(24576,'24 GB'),(32768,'32 GB'),(49152,'48 GB'),(65536,'64 GB')] %}
                <option value="{{ mb }}" {% if vm.memory_mb|int == mb %}selected{% endif %}>{{ label }}</option>
                {% endfor %}
            </select>
        </div>
    </div>
    <div class="checkbox-group">
        <input type="checkbox" name="uefi" id="uefi" {% if vm_config.uefi %}checked{% endif %} {% if is_active %}disabled{% endif %}>
        <label for="uefi">UEFIブート (OVMF)</label>
    </div>
    <div class="checkbox-group">
        <input type="checkbox" name="tpm_enabled" id="tpm_enabled" {% if vm_config.tpm_enabled %}checked{% endif %} {% if is_active %}disabled{% endif %}>
        <label for="tpm_enabled">TPM v2.0</label>
    </div>
    <div class="checkbox-group" id="secureboot-group" style="{% if not vm_config.uefi %}display:none{% endif %}">
        <input type="checkbox" name="secure_boot" id="secure_boot" {% if vm_config.secure_boot %}checked{% endif %} {% if is_active %}disabled{% endif %}>
        <label for="secure_boot">Secure Boot</label>
        <small style="margin-left:5px;color:var(--text-secondary)">Windows 11等で推奨。CachyOS等は非推奨</small>
    </div>
</div>

<div class="card">
    <h2><i class="fas fa-hdd"></i> ディスク</h2>
    <p style="color:var(--text-secondary);font-size:0.85em;margin-bottom:10px">{% if is_active %}停止後に変更可能{% else %}↑↓でブート順序を変更{% endif %}</p>
    {% if devices.disks %}
    <table>
        <thead><tr><th></th><th>デバイス</th><th>タイプ</th><th>ソース</th><th>バス</th><th></th></tr></thead>
        <tbody id="disk-list">
        {% for d in devices.disks %}
        <tr data-target="{{ d.target_dev }}" data-device="{{ d.device }}">
            <td style="cursor:default;color:var(--text-secondary);font-weight:bold">{{ loop.index }}.</td>
            <td>{{ d.target_dev or '-' }}</td>
            <td><span class="status-badge status-running">{{ d.type }}/{{ d.device }}</span></td>
            <td>{{ d.source_file or d.source_dev or d.source_name or '-' }}</td>
            <td>{{ d.target_bus or '-' }}</td>
            <td style="white-space:nowrap">
                {% if not is_active %}
                {% if loop.index > 1 %}<button type="button" class="btn btn-secondary btn-sm" onclick="moveDisk(this,-1)" title="上へ"><i class="fas fa-arrow-up"></i></button>{% endif %}
                {% if not loop.last %}<button type="button" class="btn btn-secondary btn-sm" onclick="moveDisk(this,1)" title="下へ"><i class="fas fa-arrow-down"></i></button>{% endif %}
                {% endif %}
                {% if d.device == 'cdrom' %}
                <button type="button" class="btn btn-info btn-sm" onclick="changeIso('{{ d.target_dev }}')" title="ISO変更"><i class="fas fa-exchange-alt"></i></button>
                {% endif %}
                {% if d.driver_type == 'qcow2' and not is_active %}
                <button type="button" class="btn btn-warning btn-sm" onclick="resizeDisk('{{ d.target_dev }}', '{{ d.source_file }}')" title="ディスク拡大"><i class="fas fa-expand-arrows-alt"></i></button>
                {% endif %}
                {% if not is_active %}
                <button type="button" class="btn btn-danger btn-sm" onclick="detachDisk('{{ d.target_dev }}', '{{ d.device }}')" title="削除"><i class="fas fa-trash"></i></button>
                {% endif %}
            </td>
        </tr>
        {% endfor %}
        </tbody>
    </table>
    {% else %}
    <p style="color:var(--text-secondary)">ディスクがありません</p>
    {% endif %}

    <h3 style="margin-top:15px">ディスク追加</h3>
    <div id="new-disks"></div>
    <button type="button" class="btn btn-primary btn-sm" onclick="addNewDisk()"><i class="fas fa-plus"></i> ディスク追加</button>

    <h3 style="margin-top:15px"><i class="fas fa-compact-disc"></i> ISO / CD-ROM</h3>
    <div id="iso-disks"></div>
    <div style="display:flex;gap:8px;align-items:end;margin-top:8px">
        <div class="form-group" style="flex:1;margin:0">
            <label>プールから選択</label>
            <select id="iso-pool-select">
                <option value="">-- 選択してください --</option>
            </select>
        </div>
        <button type="button" class="btn btn-primary btn-sm" onclick="addIsoFromPool()" style="margin-bottom:2px"><i class="fas fa-plus"></i> 追加</button>
    </div>
    <button type="button" class="btn btn-secondary btn-sm" onclick="addIsoDisk()" style="margin-top:8px"><i class="fas fa-keyboard"></i> 手動入力</button>
</div>

<div class="card">
    <div class="card-header">
        <h2><i class="fas fa-usb"></i> USBデバイスパススルー</h2>
    </div>
    <p style="color:var(--text-secondary);font-size:0.85em;margin-bottom:10px">ホストUSBデバイスをVMに接続・取り外しできます</p>
    <div id="usb-attached-list"></div>
    <h3 style="margin-top:10px">ホストUSBデバイス</h3>
    <div id="usb-host-list"></div>
</div>

<div class="card">
    <div class="card-header">
        <h2><i class="fas fa-tv"></i> グラフィックス</h2>
    </div>
    <p style="color:var(--text-secondary);margin-bottom:12px;font-size:0.9em">VNCとSPICEを<strong>両方</strong>有効にできます</p>
    <h3>VNC</h3>
    <div class="form-row">
        <div class="form-group">
            <label>ポート</label>
            <input type="text" name="vnc_port" value="{{ vm_config.vnc_port }}" {% if is_active %}disabled{% endif %}>
        </div>
        <div class="form-group">
            <label>リッスン</label>
            <input type="text" name="vnc_listen" value="{{ vm_config.vnc_listen }}" {% if is_active %}disabled{% endif %}>
        </div>
    </div>
    <h3 style="margin-top:15px">SPICE</h3>
    <div class="checkbox-group">
        <input type="checkbox" name="spice_enabled" id="spice_enabled" {% if vm_config.spice_enabled %}checked{% endif %} {% if is_active %}disabled{% endif %}>
        <label for="spice_enabled">SPICEを有効にする</label>
    </div>
    <div id="spice-options" style="{% if not vm_config.spice_enabled %}display:none{% endif %}">
        <div class="form-row">
            <div class="form-group">
                <label>ポート</label>
                <input type="text" name="spice_port" value="{{ vm_config.spice_port }}" {% if is_active %}disabled{% endif %}>
            </div>
            <div class="form-group">
                <label>TLSポート</label>
                <input type="text" name="spice_tls_port" value="" {% if is_active %}disabled{% endif %}>
            </div>
        </div>
        <div class="form-group">
            <label>リッスン</label>
            <input type="text" name="spice_listen" value="{{ vm_config.spice_listen }}" {% if is_active %}disabled{% endif %}>
        </div>
    </div>
</div>

<div class="card">
    <h2><i class="fas fa-tv"></i> ビデオ</h2>
    <div class="form-group">
        <label>ビデオモデル</label>
        <select name="video_model" {% if is_active %}disabled{% endif %}>
            <option value="">自動</option>
            {% for v in [('virtio','VirtIO GPU'),('qxl','QXL'),('cirrus','cirrus'),('none','デバイスなし')] %}
            <option value="{{ v[0] }}" {% if vm_config.video_model == v[0] %}selected{% endif %}>{{ v[1] }}</option>
            {% endfor %}
        </select>
    </div>
</div>

<div class="card">
    <div class="card-header">
        <h2><i class="fas fa-network-wired"></i> ネットワーク</h2>
    </div>
    <div class="form-row">
        <div class="form-group">
            <label>ネットワークタイプ</label>
            <select name="net_type" {% if is_active %}disabled{% endif %}>
                {% for n in devices.networks %}
                <option value="{{ n.type }}" selected>{{ n.type }}</option>
                {% endfor %}
                {% if not devices.networks %}
                <option value="network">Libvirt仮想ネットワーク</option>
                {% endif %}
            </select>
        </div>
        <div class="form-group">
            <label>ネットワークソース</label>
            <select name="net_source" {% if is_active %}disabled{% endif %}>
                {% for n in devices.networks %}
                <option value="{{ n.source_network }}" selected>{{ n.source_network }}</option>
                {% endfor %}
                {% for net in networks %}
                <option value="{{ net.name }}">{{ net.name }}</option>
                {% endfor %}
            </select>
        </div>
    </div>
    <div class="form-group">
        <label>ネットワークモデル</label>
        <select name="net_model" {% if is_active %}disabled{% endif %}>
            {% for n in devices.networks %}
            <option value="{{ n.model }}" selected>{{ n.model }}</option>
            {% endfor %}
            {% if not devices.networks %}
            <option value="virtio">virtio</option>
            {% endif %}
            <option value="virtio">virtio</option>
            <option value="e1000">E1000</option>
            <option value="e1000e">E1000e</option>
            <option value="rtl8139">RTL8139</option>
        </select>
    </div>
</div>

<div class="card">
    <div class="card-header">
        <h2><i class="fas fa-volume-up"></i> サウンド / チャンネル / USBリダイレクター</h2>
    </div>
    <div class="checkbox-group">
        <input type="checkbox" name="sound_enabled" id="sound_enabled" {% if vm_config.sound_enabled %}checked{% endif %} {% if is_active %}disabled{% endif %}>
        <label for="sound_enabled">サウンドデバイス (ich9)</label>
        <small style="margin-left:5px;color:var(--text-secondary)">SPICEオーディオに使用</small>
    </div>
    <div class="checkbox-group">
        <input type="checkbox" name="channel_spice" id="channel_spice" {% if vm_config.channel_spice %}checked{% endif %} {% if is_active %}disabled{% endif %}>
        <label for="channel_spice">SPICE Agent チャンネル</label>
        <small style="margin-left:5px;color:var(--text-secondary)">クリップボード共有・モニタ自動調整に必要</small>
    </div>
    <div class="checkbox-group">
        <input type="checkbox" name="usb_redirector_1" id="usb_redirector_1" {% if vm_config.usb_redirector_1 %}checked{% endif %} {% if is_active %}disabled{% endif %}>
        <label for="usb_redirector_1">USB リダイレクター 1</label>
        <small style="margin-left:5px;color:var(--text-secondary)">SPICE経由でホストUSBを転送</small>
    </div>
    <div class="checkbox-group">
        <input type="checkbox" name="usb_redirector_2" id="usb_redirector_2" {% if vm_config.usb_redirector_2 %}checked{% endif %} {% if is_active %}disabled{% endif %}>
        <label for="usb_redirector_2">USB リダイレクター 2</label>
        <small style="margin-left:5px;color:var(--text-secondary)">2台目のUSB転送用</small>
    </div>
</div>

{% if not is_active %}
<div style="text-align:right;margin-top:20px;padding-bottom:30px">
    <button type="button" class="btn btn-secondary" onclick="window.history.back()" style="margin-right:10px">キャンセル</button>
    <button type="submit" class="btn btn-primary" style="font-size:1em;padding:12px 30px"><i class="fas fa-save"></i> 設定を保存</button>
</div>
{% endif %}
</form>

<div class="card" style="margin-top:30px;border-top:2px solid var(--border);padding-top:20px">
    <div class="card-header">
        <h2><i class="fas fa-code"></i> XML設定</h2>
        {% if not is_active %}
        <button class="btn btn-primary" onclick="saveXml()"><i class="fas fa-save"></i> 保存</button>
        {% endif %}
    </div>
    <textarea id="xml-editor" class="xml-editor" {% if is_active %}readonly{% endif %}>{{ xml }}</textarea>
</div>

{% endblock %}

{% block scripts %}
<script>
let newDiskCount = 0;
let isoDiskCount = 0;

document.getElementById('spice_enabled').addEventListener('change', function() {
    document.getElementById('spice-options').style.display = this.checked ? 'block' : 'none';
});

function addNewDisk() {
    newDiskCount++;
    const idx = newDiskCount;
    const html = `
    <div class="disk-item" id="new-disk-${idx}">
        <button type="button" class="remove-btn" onclick="this.parentElement.remove()"><i class="fas fa-times"></i></button>
        <div class="form-row-3">
            <div class="form-group">
                <label>タイプ</label>
                <select name="new_type_${idx}" onchange="updateDiskSourceWidget(this, ${idx})">
                    <option value="file">ファイル</option>
                    <option value="block">ブロックデバイス</option>
                </select>
            </div>
            <div class="form-group" id="new-source-${idx}">
                <label>ソースパス</label>
                <input type="text" name="new_source_${idx}" placeholder="/dev/sdb または /path/to/disk.qcow2">
            </div>
            <div class="form-group">
                <label>ターゲット</label>
                <select name="new_target_${idx}">
                    <option value="vdb">vdb</option>
                    <option value="vdc">vdc</option>
                    <option value="vdd">vdd</option>
                    <option value="vde">vde</option>
                    <option value="vdf">vdf</option>
                </select>
            </div>
        </div>
        <div class="form-row">
            <div class="form-group">
                <label>バス</label>
                <select name="new_bus_${idx}">
                    <option value="virtio">virtio</option>
                    <option value="sata">SATA</option>
                    <option value="scsi">SCSI</option>
                </select>
            </div>
            <div class="form-group">
                <label>ドライバー</label>
                <select name="new_driver_${idx}">
                    <option value="qcow2">qcow2</option>
                    <option value="raw">raw</option>
                </select>
            </div>
            <div class="form-group">
                <label>&nbsp;</label>
                <button type="button" class="btn btn-primary btn-sm" onclick="confirmAddDisk(${idx})"><i class="fas fa-plus"></i> 追加</button>
            </div>
        </div>
    </div>`;
    document.getElementById('new-disks').insertAdjacentHTML('beforeend', html);
    updateDiskSourceWidget(document.querySelector(`[name="new_type_${idx}"]`), idx);
}

function confirmAddDisk(idx) {
    const dtype = document.querySelector(`[name="new_type_${idx}"]`).value;
    const srcEl = document.querySelector(`[name="new_source_${idx}"]`);
    const src = srcEl ? srcEl.value.trim() : '';
    const targetDev = document.querySelector(`[name="new_target_${idx}"]`).value;
    const targetBus = document.querySelector(`[name="new_bus_${idx}"]`).value;
    const driverType = document.querySelector(`[name="new_driver_${idx}"]`).value;

    if (!src) { alert('ソースを選択してください'); return; }

    let diskXml;
    if (dtype === 'block') {
        diskXml = `<disk type='block' device='disk'><driver name='qemu' type='${driverType}'/><source dev='${src}'/><target dev='${targetDev}' bus='${targetBus}'/></disk>`;
    } else {
        diskXml = `<disk type='file' device='disk'><driver name='qemu' type='${driverType}'/><source file='${src}'/><target dev='${targetDev}' bus='${targetBus}'/></disk>`;
    }

    if (!confirm(`ディスク '${targetDev}' を追加しますか?`)) return;
    document.getElementById(`new-disk-${idx}`).remove();

    fetch(`/api/vm/{{ vm.name }}/action`, {
        method: 'POST',
        headers: {'Content-Type': 'application/json'},
        body: JSON.stringify({action: 'disk_attach', xml: diskXml})
    })
    .then(r => r.json())
    .then(d => { if (d.error) alert('エラー: ' + d.error); else { alert('ディスクを追加しました'); location.reload(); }})
    .catch(err => alert('通信エラー: ' + err));
}

function updateDiskSourceWidget(sel, idx) {
    const item = sel.closest('.disk-item');
    const target = item.querySelector(`[name="new_target_${idx}"]`);
    const bus = item.querySelector(`[name="new_bus_${idx}"]`);
    const driver = item.querySelector(`[name="new_driver_${idx}"]`);
    const container = document.getElementById(`new-source-${idx}`);
    if (!container) return;

    if (sel.value === 'block') {
        target.value = 'vdb';
        bus.value = 'virtio';
        driver.value = 'raw';
        container.innerHTML = '<label>ブロックデバイス</label><select name="new_source_' + idx + '" style="width:100%"><option value="">読み込み中...</option></select>';
        fetch('/api/block-devices').then(r => r.json()).then(devs => {
            const s = container.querySelector('select');
            s.innerHTML = '<option value="">-- デバイスを選択 --</option>';
            devs.forEach(d => {
                const opt = document.createElement('option');
                opt.value = d.path;
                opt.textContent = d.label;
                s.appendChild(opt);
            });
        });
    } else {
        target.value = 'vdb';
        bus.value = 'virtio';
        driver.value = 'qcow2';
        container.innerHTML = '<label>ストレージプール ボリューム</label><select name="new_source_' + idx + '" style="width:100%"><option value="">読み込み中...</option></select>';
        fetch('/api/storage-pool-volumes').then(r => r.json()).then(vols => {
            const s = container.querySelector('select');
            s.innerHTML = '<option value="">-- ボリュームを選択 --</option>';
            vols.filter(v => v.name.endsWith('.qcow2')).forEach(v => {
                const opt = document.createElement('option');
                opt.value = v.path;
                opt.textContent = v.label;
                s.appendChild(opt);
            });
            if (!s.options.length || s.options.length === 1) {
                s.innerHTML = '<option value="">qcow2ボリュームがありません</option>';
            }
        });
    }
}

function addIsoDisk() {
    isoDiskCount++;
    const html = `
    <div class="disk-item" id="iso-disk-${isoDiskCount}">
        <button type="button" class="remove-btn" onclick="this.parentElement.remove()"><i class="fas fa-times"></i></button>
        <div class="form-row">
            <div class="form-group">
                <label>ISOファイルパス</label>
                <input type="text" name="iso_path_${isoDiskCount}" placeholder="/path/to/file.iso">
            </div>
        </div>
    </div>`;
    document.getElementById('iso-disks').insertAdjacentHTML('beforeend', html);
}

function addIsoFromPool() {
    const sel = document.getElementById('iso-pool-select');
    if (!sel || !sel.value) { alert('ISOファイルを選択してください'); return; }
    const isActive = {{ 'true' if is_active else 'false' }};
    if (isActive) {
        if (!confirm('稼働中のVMにCD-ROMを接続しますか?')) return;
        const isoPath = sel.value;
        const devs = ['sdc','sdd','sde','sdf','sdg','sdh','sdi','sdj'];
        let dev = devs[0];
        fetch(`/api/vm/{{ vm.name }}/xml`).then(r=>r.json()).then(data => {
            const doc = new DOMParser().parseFromString(data.xml, 'text/xml');
            const usedDevs = [...doc.querySelectorAll('disk[target]')].map(d => d.querySelector('target').getAttribute('dev'));
            for (const d of devs) { if (!usedDevs.includes(d)) { dev = d; break; } }
            const diskXml = `<disk type='file' device='cdrom'><driver name='qemu' type='raw'/><source file='${isoPath}'/><target dev='${dev}' bus='sata'/><readonly/></disk>`;
            fetch(`/api/vm/{{ vm.name }}/action`, {
                method: 'POST',
                headers: {'Content-Type': 'application/json'},
                body: JSON.stringify({action: 'disk_attach', xml: diskXml})
            }).then(r=>r.json()).then(d => {
                if (d.error) alert('エラー: ' + d.error);
                else { alert('CD-ROMを接続しました'); location.reload(); }
            }).catch(err => alert('通信エラー: ' + err));
        });
        sel.value = '';
    } else {
        isoDiskCount++;
        const html = `
        <div class="disk-item" id="iso-disk-${isoDiskCount}">
            <button type="button" class="remove-btn" onclick="this.parentElement.remove()"><i class="fas fa-times"></i></button>
            <div class="form-row">
                <div class="form-group">
                    <label>ISOファイルパス</label>
                    <input type="text" name="iso_path_${isoDiskCount}" value="${sel.value}" readonly>
                </div>
            </div>
        </div>`;
        document.getElementById('iso-disks').insertAdjacentHTML('beforeend', html);
        sel.value = '';
    }
}

function loadIsoFiles() {
    const sel = document.getElementById('iso-pool-select');
    if (!sel) return;
    fetch('/api/iso-files').then(r=>r.json()).then(isos => {
        isos.sort((a,b) => a.name.localeCompare(b.name));
        isos.forEach(iso => {
            const opt = document.createElement('option');
            opt.value = iso.path;
            opt.textContent = iso.label;
            sel.appendChild(opt);
        });
    });
}

async function collectFormData() {
    const fd = new FormData(document.getElementById('vm-form'));

    const bootOrder = [];
    document.querySelectorAll('#disk-list tr').forEach(tr => {
        const dev = tr.dataset.device;
        if (dev === 'disk') bootOrder.push('hd');
        else if (dev === 'cdrom') bootOrder.push('cdrom');
    });

    let currentUsbs = [];
    try {
        const xmlResp = await fetch(`/api/vm/{{ vm.name }}/xml`);
        const xmlData = await xmlResp.json();
        const doc = new DOMParser().parseFromString(xmlData.xml, 'text/xml');
        doc.querySelectorAll('hostdev[type="usb"]').forEach(hd => {
            const v = hd.querySelector('vendor')?.getAttribute('id') || '';
            const p = hd.querySelector('product')?.getAttribute('id') || '';
            if (v && p) currentUsbs.push({vendor_id: v, product_id: p});
        });
    } catch(e) {}

    return {
        domain_type: fd.get('domain_type'),
        arch: '{{ os_info.arch }}',
        machine: fd.get('machine'),
        vcpus: fd.get('vcpus'),
        memory_mb: fd.get('memory_mb'),
        uefi: fd.has('uefi'),
        tpm_enabled: fd.has('tpm_enabled'),
        secure_boot: fd.has('secure_boot'),
        sound_enabled: fd.has('sound_enabled'),
        channel_spice: fd.has('channel_spice'),
        usb_redirector_1: fd.has('usb_redirector_1'),
        usb_redirector_2: fd.has('usb_redirector_2'),
        vnc_port: fd.get('vnc_port'),
        vnc_listen: fd.get('vnc_listen'),
        spice_enabled: fd.has('spice_enabled'),
        spice_port: fd.get('spice_port'),
        spice_tls_port: fd.get('spice_tls_port'),
        spice_listen: fd.get('spice_listen'),
        video_model: fd.get('video_model'),
        net_type: fd.get('net_type'),
        net_source: fd.get('net_source'),
        net_model: fd.get('net_model'),
        boot_order: bootOrder,
        existing_disks: [
            {% for d in devices.disks %}
            {type:'{{ d.type }}',device:'{{ d.device }}',target_dev:'{{ d.target_dev }}',target_bus:'{{ d.target_bus }}',source_file:'{{ d.source_file }}',source_dev:'{{ d.source_dev }}',source_name:'{{ d.source_name }}',source_protocol:'{{ d.source_protocol }}',driver_type:'{{ d.driver_type }}',readonly:{{ 'true' if d.device == 'cdrom' else 'false' }}},
            {% endfor %}
        ],
        existing_usbs: currentUsbs,
        iso_paths: [],
        hostdevs: [],
        usb_hostdevs: newUsbs
    };
}

document.getElementById('vm-form').addEventListener('submit', async function(e) {
    e.preventDefault();
    const data = await collectFormData();
    for (let i = 1; i <= 50; i++) {
        const src = document.querySelector(`[name="iso_path_${i}"]`);
        if (src && src.value.trim()) data.iso_paths.push(src.value.trim());
    }
    if (!confirm('設定を保存しますか?')) return;
    fetch('/vm/{{ vm.name }}/edit', {
        method: 'POST',
        headers: {'Content-Type': 'application/json'},
        body: JSON.stringify(data)
    })
    .then(r => r.json())
    .then(r => { if (r.error) alert('エラー: ' + r.error); else { alert('保存しました'); location.reload(); }})
    .catch(err => alert('通信エラー: ' + err));
});

function vmAction(action) {
    if (action === 'undefine') {
        if (!confirm('このVMを削除しますか?')) return;
        const deleteDisk = confirm('ディスクファイルも削除しますか?');
        fetch(`/api/vm/{{ vm.name }}/action`, {
            method: 'POST',
            headers: {'Content-Type': 'application/json'},
            body: JSON.stringify({action: action, delete_disk: deleteDisk})
        })
        .then(r => r.json())
        .then(d => { if (d.error) alert('エラー: ' + d.error); else window.location.href = '/'; })
        .catch(err => alert('通信エラー: ' + err));
        return;
    }
    const msgs = {stop:'シャットダウンしますか?',destroy:'強制停止しますか?',reboot:'再起動しますか?',suspend:'一時停止しますか?'};
    if (msgs[action] && !confirm(msgs[action])) return;
    fetch(`/api/vm/{{ vm.name }}/action`, {
        method: 'POST',
        headers: {'Content-Type': 'application/json'},
        body: JSON.stringify({action: action})
    })
    .then(r => r.json())
    .then(d => { if (d.error) alert('エラー: ' + d.error); else location.reload(); })
    .catch(err => alert('通信エラー: ' + err));
}

function saveXml() {
    const xml = document.getElementById('xml-editor').value;
    if (!confirm('XML設定を保存しますか?')) return;
    fetch(`/api/vm/{{ vm.name }}/xml`, {
        method: 'PUT',
        headers: {'Content-Type': 'application/json'},
        body: JSON.stringify({xml: xml})
    })
    .then(r => r.json())
    .then(d => { if (d.error) alert('エラー: ' + d.error); else alert('保存しました'); })
    .catch(err => alert('通信エラー: ' + err));
}

function moveDisk(btn, dir) {
    const tr = btn.closest('tr');
    const tbody = document.getElementById('disk-list');
    const rows = [...tbody.querySelectorAll('tr')];
    const idx = rows.indexOf(tr);
    const ni = idx + dir;
    if (ni < 0 || ni >= rows.length) return;
    if (dir === -1) tbody.insertBefore(tr, rows[ni]);
    else tbody.insertBefore(rows[ni], tr);
    updateDiskNumbers();
}

function updateDiskNumbers() {
    document.querySelectorAll('#disk-list tr').forEach((tr, i) => {
        const num = tr.querySelector('td:first-child');
        if (num) num.textContent = (i+1) + '.';
        const btns = tr.querySelectorAll('td:last-child button');
        btns.forEach(b => b.remove());
        const td = tr.querySelector('td:last-child');
        if (!td) return;
        const target = tr.dataset.target;
        const device = tr.dataset.device;
        const isActive = {{ 'true' if is_active else 'false' }};
        if (!isActive) {
            if (i > 0) td.innerHTML += `<button type="button" class="btn btn-secondary btn-sm" onclick="moveDisk(this,-1)" title="上へ"><i class="fas fa-arrow-up"></i></button>`;
            if (i < document.querySelectorAll('#disk-list tr').length - 1) td.innerHTML += `<button type="button" class="btn btn-secondary btn-sm" onclick="moveDisk(this,1)" title="下へ"><i class="fas fa-arrow-down"></i></button>`;
        }
        if (device === 'cdrom') td.innerHTML += `<button type="button" class="btn btn-info btn-sm" onclick="changeIso('${target}')" title="ISO変更"><i class="fas fa-exchange-alt"></i></button>`;
        if (!isActive) td.innerHTML += `<button type="button" class="btn btn-danger btn-sm" onclick="detachDisk('${target}', '${device}')" title="削除"><i class="fas fa-trash"></i></button>`;
    });
}

function detachDisk(targetDev, deviceType) {
    const label = deviceType === 'cdrom' ? 'CD-ROM' : 'ディスク';
    if (!confirm(`${label} '${targetDev}' を削除しますか?`)) return;
    fetch(`/api/vm/{{ vm.name }}/action`, {
        method: 'POST',
        headers: {'Content-Type': 'application/json'},
        body: JSON.stringify({action: 'disk_detach', target_dev: targetDev})
    })
    .then(r => r.json())
    .then(d => { if (d.error) alert('エラー: ' + d.error); else { alert('削除しました'); location.reload(); }})
    .catch(err => alert('通信エラー: ' + err));
}

function changeIso(targetDev) {
    let existing = document.getElementById('iso-change-modal');
    if (existing) existing.remove();

    const modal = document.createElement('div');
    modal.id = 'iso-change-modal';
    modal.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;z-index:1000';
    modal.innerHTML = `
    <div style="background:var(--bg-card);border-radius:8px;padding:20px;min-width:400px;max-width:90vw">
        <h3 style="margin-top:0">CD-ROM '${targetDev}' のISO変更</h3>
        <div class="form-group">
            <label>プールから選択</label>
            <select id="iso-change-select" style="width:100%">
                <option value="">-- 選択してください --</option>
                <option value="__eject__">ISOなし(取り出し)</option>
            </select>
        </div>
        <div class="form-group">
            <label>または手動入力</label>
            <input type="text" id="iso-change-input" placeholder="/path/to/file.iso" style="width:100%">
        </div>
        <div style="text-align:right;margin-top:15px">
            <button type="button" class="btn btn-secondary" onclick="document.getElementById('iso-change-modal').remove()" style="margin-right:8px">キャンセル</button>
            <button type="button" class="btn btn-primary" onclick="confirmIsoChange('${targetDev}')">変更</button>
        </div>
    </div>`;
    document.body.appendChild(modal);

    fetch('/api/iso-files').then(r => r.json()).then(isos => {
        const sel = document.getElementById('iso-change-select');
        isos.sort((a, b) => a.name.localeCompare(b.name));
        isos.forEach(iso => {
            const opt = document.createElement('option');
            opt.value = iso.path;
            opt.textContent = iso.label;
            sel.appendChild(opt);
        });
    });

    document.getElementById('iso-change-select').addEventListener('change', function() {
        if (this.value === '__eject__') {
            document.getElementById('iso-change-input').value = '';
        } else {
            document.getElementById('iso-change-input').value = this.value;
        }
    });
}

function confirmIsoChange(targetDev) {
    const input = document.getElementById('iso-change-input');
    const sel = document.getElementById('iso-change-select');
    let newSource = (input && input.value.trim()) || (sel && sel.value);
    if (newSource === '__eject__') newSource = '';
    if (!newSource && sel && sel.value !== '__eject__' && !input.value.trim()) { alert('ISOファイルを選択または入力してください'); return; }
    const label = newSource ? 'ISOを変更' : 'ISOを取り出し';
    if (!confirm(`CD-ROM '${targetDev}' を${label}しますか?`)) return;
    document.getElementById('iso-change-modal').remove();
    fetch(`/api/vm/{{ vm.name }}/action`, {
        method: 'POST',
        headers: {'Content-Type': 'application/json'},
        body: JSON.stringify({action: 'disk_update_source', target_dev: targetDev, new_source: newSource})
    })
    .then(r => r.json())
    .then(d => { if (d.error) alert('エラー: ' + d.error); else { alert(`${label}しました`); location.reload(); }})
    .catch(err => alert('通信エラー: ' + err));
}

function resizeDisk(targetDev, sourcePath) {
    let existing = document.getElementById('disk-resize-modal');
    if (existing) existing.remove();

    const fileName = sourcePath ? sourcePath.split('/').pop() : targetDev;
    const modal = document.createElement('div');
    modal.id = 'disk-resize-modal';
    modal.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;z-index:1000';
    modal.innerHTML = `
    <div style="background:var(--bg-card);border-radius:8px;padding:20px;min-width:380px;max-width:90vw">
        <h3 style="margin-top:0">ディスク拡大</h3>
        <p style="color:var(--text-secondary);font-size:0.9em;margin-bottom:15px">
            対象: <strong>${fileName}</strong><br>
            パス: <span style="font-size:0.85em">${sourcePath || '-'}</span>
        </p>
        <div class="form-group">
            <label>新しいサイズ</label>
            <input type="text" id="disk-resize-input" placeholder="例: 100G, 200G" style="width:100%">
            <small style="color:var(--text-secondary)">例: 100G (+100GiB), 500M (+500MiB)</small>
        </div>
        <div style="text-align:right;margin-top:15px">
            <button type="button" class="btn btn-secondary" onclick="document.getElementById('disk-resize-modal').remove()" style="margin-right:8px">キャンセル</button>
            <button type="button" class="btn btn-warning" onclick="confirmDiskResize('${targetDev}')">拡大</button>
        </div>
    </div>`;
    document.body.appendChild(modal);
}

function confirmDiskResize(targetDev) {
    const input = document.getElementById('disk-resize-input');
    const newSize = input ? input.value.trim() : '';
    if (!newSize) { alert('拡大するサイズを入力してください'); return; }
    if (!confirm(`ディスク '${targetDev}' を ${newSize} に拡大しますか?`)) return;
    document.getElementById('disk-resize-modal').remove();
    fetch(`/api/vm/{{ vm.name }}/action`, {
        method: 'POST',
        headers: {'Content-Type': 'application/json'},
        body: JSON.stringify({action: 'disk_resize', target_dev: targetDev, new_size: newSize})
    })
    .then(r => r.json())
    .then(d => {
        if (d.error) {
            alert('エラー: ' + d.error);
        } else {
            const oldGB = d.old_size ? (d.old_size / 1073741824).toFixed(1) : '?';
            const newGB = d.new_size ? (d.new_size / 1073741824).toFixed(1) : '?';
            alert(`拡大しました!\\n${oldGB} GB → ${newGB} GB\\n\\nゲストOS内でもパーティションを拡張してください。`);
            location.reload();
        }
    })
    .catch(err => alert('通信エラー: ' + err));
}

function usbAttach(vid, pid, label) {
    if (!confirm(`${label} をVMに接続しますか?`)) return;
    fetch(`/api/vm/{{ vm.name }}/action`, {
        method: 'POST',
        headers: {'Content-Type': 'application/json'},
        body: JSON.stringify({action:'usb_attach', vendor_id:vid, product_id:pid})
    })
    .then(r => r.json())
    .then(d => { if (d.error) alert('エラー: ' + d.error); else { alert('接続しました'); loadUsbDevices(); }})
    .catch(err => alert('通信エラー: ' + err));
}

function usbDetach(vid, pid, label) {
    if (!confirm(`${label} をVMから取り外しますか?`)) return;
    fetch(`/api/vm/{{ vm.name }}/action`, {
        method: 'POST',
        headers: {'Content-Type': 'application/json'},
        body: JSON.stringify({action:'usb_detach', vendor_id:vid, product_id:pid})
    })
    .then(r => r.json())
    .then(d => { if (d.error) alert('エラー: ' + d.error); else { alert('取り外しました'); loadUsbDevices(); }})
    .catch(err => alert('通信エラー: ' + err));
}

function loadUsbDevices() {
    const isActive = {{ 'true' if is_active else 'false' }};
    Promise.all([
        fetch(`/api/vm/{{ vm.name }}/xml`).then(r=>r.json()),
        fetch('/api/usb-devices').then(r=>r.json())
    ]).then(([xmlData, hostDevs]) => {
        const doc = new DOMParser().parseFromString(xmlData.xml, 'text/xml');
        const usbs = doc.querySelectorAll('hostdev[type="usb"]');
        const nameMap = {};
        hostDevs.forEach(d => { nameMap[d.vendor_id.toLowerCase() + ':' + d.product_id.toLowerCase()] = d.name; });

        let html = '';
        if (usbs.length) {
            html = '<table><thead><tr><th>デバイス</th><th></th></tr></thead><tbody>';
            usbs.forEach(hd => {
                const v = hd.querySelector('vendor')?.getAttribute('id') || '';
                const p = hd.querySelector('product')?.getAttribute('id') || '';
                const key = v.replace('0x','').toLowerCase() + ':' + p.replace('0x','').toLowerCase();
                const devName = nameMap[key] || '不明';
                html += `<tr><td>${v}:${p} <span style="color:var(--text-secondary)">(${devName})</span></td>`;
                html += `<td><button type="button" class="btn btn-danger btn-sm" onclick="usbDetach('${v.replace('0x','')}','${p.replace('0x','')}','${v}:${p}')"><i class="fas fa-times"></i> 取り外し</button></td>`;
                html += '</tr>';
            });
            html += '</tbody></table>';
        } else {
            html = '<p style="color:var(--text-secondary)">接続中のUSBデバイスはありません</p>';
        }
        document.getElementById('usb-attached-list').innerHTML = html;
    });
    fetch('/api/usb-devices').then(r=>r.json()).then(devs => {
        let html = '';
        devs.forEach(d => {
            html += `<div style="display:flex;align-items:center;gap:10px;margin:4px 0"><span style="font-size:0.9em">${d.label}</span>`;
            const safeLabel = d.label.replace(/'/g, "\\'");
            html += `<button type="button" class="btn btn-success btn-sm" onclick="usbAttach('${d.vendor_id}','${d.product_id}','${safeLabel}')"><i class="fas fa-plug"></i> 接続</button>`;
            html += '</div>';
        });
        document.getElementById('usb-host-list').innerHTML = html || '<p style="color:var(--text-secondary)">USBデバイスなし</p>';
    });
}

document.addEventListener('DOMContentLoaded', function() {
    loadUsbDevices();
    loadIsoFiles();
    loadSnapshots();
});

function loadSnapshots() {
    fetch(`/api/vm/{{ vm.name }}/snapshots`).then(r => r.json()).then(snaps => {
        const el = document.getElementById('snapshot-list');
        if (!snaps.length) {
            el.innerHTML = '<p style="color:var(--text-secondary)">スナップショットはありません</p>';
            return;
        }
        let html = '<table><thead><tr><th>名前</th><th>状態</th><th>作成日時</th><th>説明</th><th></th></tr></thead><tbody>';
        snaps.forEach(s => {
            const date = s.creation ? new Date(s.creation * 1000).toLocaleString('ja-JP') : '-';
            const stateLabel = s.state === 'running' ? '稼働中' : s.state === 'shutoff' ? '停止中' : s.state;
            html += `<tr>`;
            html += `<td><strong>${s.name}</strong></td>`;
            html += `<td><span class="status-badge status-${s.state === 'running' ? 'running' : 'stopped'}">${stateLabel}</span></td>`;
            html += `<td style="font-size:0.85em">${date}</td>`;
            html += `<td style="font-size:0.85em;color:var(--text-secondary)">${s.description || '-'}</td>`;
            html += `<td style="white-space:nowrap">`;
            html += `<button class="btn btn-info btn-sm" onclick="revertSnapshot('${s.name}')" title="元に戻す"><i class="fas fa-undo"></i></button> `;
            html += `<button class="btn btn-danger btn-sm" onclick="deleteSnapshot('${s.name}')" title="削除"><i class="fas fa-trash"></i></button>`;
            html += `</td></tr>`;
        });
        html += '</tbody></table>';
        el.innerHTML = html;
    }).catch(() => {
        document.getElementById('snapshot-list').innerHTML = '<p style="color:var(--text-secondary)">取得できません</p>';
    });
}

function createSnapshot() {
    let existing = document.getElementById('snapshot-create-modal');
    if (existing) existing.remove();

    const modal = document.createElement('div');
    modal.id = 'snapshot-create-modal';
    modal.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;z-index:1000';
    modal.innerHTML = `
    <div style="background:var(--bg-card);border-radius:8px;padding:20px;min-width:380px;max-width:90vw">
        <h3 style="margin-top:0">スナップショット作成</h3>
        <div class="form-group">
            <label>名前</label>
            <input type="text" id="snap-name" placeholder="空なら自動生成" style="width:100%">
        </div>
        <div class="form-group">
            <label>説明</label>
            <input type="text" id="snap-desc" placeholder="オプション" style="width:100%">
        </div>
        <div style="text-align:right;margin-top:15px">
            <button type="button" class="btn btn-secondary" onclick="document.getElementById('snapshot-create-modal').remove()" style="margin-right:8px">キャンセル</button>
            <button type="button" class="btn btn-primary" onclick="confirmCreateSnapshot()">作成</button>
        </div>
    </div>`;
    document.body.appendChild(modal);
}

function confirmCreateSnapshot() {
    const name = document.getElementById('snap-name').value.trim();
    const desc = document.getElementById('snap-desc').value.trim();
    if (!confirm('スナップショットを作成しますか?')) return;
    document.getElementById('snapshot-create-modal').remove();
    fetch(`/api/vm/{{ vm.name }}/snapshot-create`, {
        method: 'POST',
        headers: {'Content-Type': 'application/json'},
        body: JSON.stringify({name: name, description: desc})
    })
    .then(r => r.json())
    .then(d => { if (d.error) alert('エラー: ' + d.error); else { alert('作成しました'); loadSnapshots(); }})
    .catch(err => alert('通信エラー: ' + err));
}

function revertSnapshot(snapName) {
    if (!confirm(`スナップショット「${snapName}」に元に戻しますか?\n現在の状態は失われます。`)) return;
    fetch(`/api/vm/{{ vm.name }}/snapshot-revert`, {
        method: 'POST',
        headers: {'Content-Type': 'application/json'},
        body: JSON.stringify({name: snapName})
    })
    .then(r => r.json())
    .then(d => { if (d.error) alert('エラー: ' + d.error); else { alert('元に戻しました'); location.reload(); }})
    .catch(err => alert('通信エラー: ' + err));
}

function deleteSnapshot(snapName) {
    if (!confirm(`スナップショット「${snapName}」を削除しますか?`)) return;
    fetch(`/api/vm/{{ vm.name }}/snapshot-delete`, {
        method: 'POST',
        headers: {'Content-Type': 'application/json'},
        body: JSON.stringify({name: snapName})
    })
    .then(r => r.json())
    .then(d => { if (d.error) alert('エラー: ' + d.error); else { alert('削除しました'); loadSnapshots(); }})
    .catch(err => alert('通信エラー: ' + err));
}
</script>

{% if is_active %}
<script type="module">
import RFB from '/novnc/core/rfb.js';

const vmName = "{{ vm.name }}";
let rfb = null;
let wsPort = null;
let reconnectTimer = null;
let reconnectDelay = 1000;

const vncStatus = document.getElementById('vnc-status');
const vncContainer = document.getElementById('vnc-container');
const btnFS = document.getElementById('vnc-btn-fullscreen');

function setVncStatus(text, color) {
    if (vncStatus) {
        vncStatus.textContent = text;
        vncStatus.style.background = color;
    }
}

function cleanupRfb() {
    if (rfb) {
        rfb.removeEventListener('connected', onConnected);
        rfb.removeEventListener('disconnected', onDisconnected);
        rfb.removeEventListener('failed', onFailed);
        rfb.disconnect();
        rfb = null;
    }
}

function onConnected() {
    setVncStatus('接続済み', '#27ae60');
    reconnectDelay = 1000;
}

function onDisconnected() {
    setVncStatus('切断 - 再接続中...', '#f39c12');
    scheduleReconnect();
}

function onFailed() {
    setVncStatus('接続失敗 - 再試行中...', '#e74c3c');
    scheduleReconnect();
}

function scheduleReconnect() {
    if (reconnectTimer) clearTimeout(reconnectTimer);
    reconnectTimer = setTimeout(() => {
        reconnectDelay = Math.min(reconnectDelay * 1.5, 10000);
        connectVnc();
    }, reconnectDelay);
}

async function connectVnc() {
    try {
        cleanupRfb();
        setVncStatus('プロキシ起動中...', '#f39c12');
        const resp = await fetch(`/api/vm/${vmName}/console-proxy`, { method: 'POST' });
        const data = await resp.json();
        if (data.error) {
            setVncStatus('エラー: ' + data.error, '#e74c3c');
            scheduleReconnect();
            return;
        }
        wsPort = data.ws_port;

        setVncStatus('接続中...', '#f39c12');
        const wsUrl = `ws://${location.hostname}:${wsPort}`;

        rfb = new RFB(vncContainer, wsUrl, {
            credentials: {},
            wsProtocols: ['binary'],
        });

        rfb.addEventListener('connected', onConnected);
        rfb.addEventListener('disconnected', onDisconnected);
        rfb.addEventListener('failed', onFailed);
        rfb.scaleViewport = true;
        rfb.resizeSession = true;
    } catch (e) {
        setVncStatus('エラー: ' + e.message, '#e74c3c');
        scheduleReconnect();
    }
}

btnFS.addEventListener('click', () => {
    if (!vncContainer) return;
    if (vncContainer.requestFullscreen) vncContainer.requestFullscreen();
    else if (vncContainer.webkitRequestFullscreen) vncContainer.webkitRequestFullscreen();
});

window.addEventListener('beforeunload', () => {
    if (reconnectTimer) clearTimeout(reconnectTimer);
    cleanupRfb();
});

connectVnc();

window.cleanupRfb = cleanupRfb;
window.connectVnc = connectVnc;

setTimeout(() => { cleanupRfb(); connectVnc(); }, 500);

document.addEventListener('visibilitychange', () => {
    if (document.visibilityState === 'visible') {
        setTimeout(() => { cleanupRfb(); connectVnc(); }, 200);
    }
});

const statusPoll = setInterval(async () => {
    try {
        const resp = await fetch(`/api/vm/${vmName}/status`);
        const data = await resp.json();
        if (!data.active) {
            clearInterval(statusPoll);
            location.reload();
        }
    } catch(e) {}
}, 3000);
</script>
{% endif %}
{% endblock %}
ENDOFFILE_VM_DETAIL.HTML

cat << 'ENDOFFILE_VM_EDIT.HTML' > /opt/vm-manage/templates/vm_edit.html
{% extends "base.html" %}
{% block title %}{{ vm.name }} 編集 - VM Manager{% endblock %}
{% block content %}
<h1><i class="fas fa-edit"></i> {{ vm.name }} を編集</h1>
<p style="color:var(--text-secondary);margin-bottom:5px">停止中のVMの設定を変更できます</p>
{% if is_active %}
<div class="alert alert-error">VMが実行中です。編集するには先に停止してください。</div>
{% endif %}

<form id="edit-form">

<div class="card">
    <h2><i class="fas fa-cog"></i> 基本設定</h2>
    <div class="form-row">
        <div class="form-group">
            <label>仮想化タイプ</label>
            <select name="domain_type" {% if is_active %}disabled{% endif %}>
                <option value="kvm" {% if vm_config.domain_type == 'kvm' %}selected{% endif %}>KVM</option>
                <option value="qemu" {% if vm_config.domain_type == 'qemu' %}selected{% endif %}>QEMU</option>
            </select>
        </div>
        <div class="form-group">
            <label>マシンタイプ</label>
            <select name="machine" {% if is_active %}disabled{% endif %}>
                <option value="pc-q35-10.2" {% if vm_config.machine == 'pc-q35-10.2' %}selected{% endif %}>pc-q35-10.2</option>
                <option value="pc-q35-9.1" {% if vm_config.machine == 'pc-q35-9.1' %}selected{% endif %}>pc-q35-9.1</option>
                <option value="pc-q35-9.0" {% if vm_config.machine == 'pc-q35-9.0' %}selected{% endif %}>pc-q35-9.0</option>
                <option value="pc-i440fx-9.1" {% if vm_config.machine == 'pc-i440fx-9.1' %}selected{% endif %}>pc-i440fx-9.1</option>
            </select>
        </div>
    </div>
    <div class="form-row">
        <div class="form-group">
            <label>vCPU数</label>
            <select name="vcpus" {% if is_active %}disabled{% endif %}>
                {% for n in [1,2,4,6,8,12,16,24,32,48,64] %}
                <option value="{{ n }}" {% if vm_config.vcpus|int == n %}selected{% endif %}>{{ n }}</option>
                {% endfor %}
            </select>
        </div>
        <div class="form-group">
            <label>メモリ (MB)</label>
            <select name="memory_mb" {% if is_active %}disabled{% endif %}>
                {% for mb, label in [(512,'512 MB'),(1024,'1 GB'),(2048,'2 GB'),(3072,'3 GB'),(4096,'4 GB'),(6144,'6 GB'),(8192,'8 GB'),(12288,'12 GB'),(16384,'16 GB'),(24576,'24 GB'),(32768,'32 GB'),(49152,'48 GB'),(65536,'64 GB')] %}
                <option value="{{ mb }}" {% if vm_config.memory_mb|int == mb %}selected{% endif %}>{{ label }}</option>
                {% endfor %}
            </select>
        </div>
    </div>
    <div class="checkbox-group">
        <input type="checkbox" name="uefi" id="uefi" {% if vm_config.uefi %}checked{% endif %} {% if is_active %}disabled{% endif %}>
        <label for="uefi">UEFIブート (OVMF)</label>
    </div>
    <div class="checkbox-group">
        <input type="checkbox" name="tpm_enabled" id="tpm_enabled" {% if vm_config.tpm_enabled %}checked{% endif %} {% if is_active %}disabled{% endif %}>
        <label for="tpm_enabled">TPM v2.0 を有効にする</label>
    </div>
    <div class="checkbox-group" id="secureboot-group" style="{% if not vm_config.uefi %}display:none{% endif %}">
        <input type="checkbox" name="secure_boot" id="secure_boot" {% if vm_config.secure_boot %}checked{% endif %} {% if is_active %}disabled{% endif %}>
        <label for="secure_boot">Secure Boot</label>
        <small style="margin-left:5px;color:var(--text-secondary)">Windows 11等で推奨。CachyOS等は非推奨</small>
    </div>
</div>

<div class="card">
    <div class="card-header">
        <h2><i class="fas fa-hdd"></i> ディスク</h2>
    </div>

    <h3>現在のディスク</h3>
    <table>
        <thead><tr><th>デバイス</th><th>タイプ</th><th>ソース</th><th>バス</th></tr></thead>
        <tbody id="existing-disks">
        {% for d in devices.disks %}
        <tr>
            <td>{{ d.target_dev }}</td>
            <td><span class="status-badge status-running">{{ d.type }}/{{ d.device }}</span></td>
            <td>{{ d.source_file or d.source_dev or d.source_name or '-' }}</td>
            <td>{{ d.target_bus }}</td>
        </tr>
        {% endfor %}
        </tbody>
    </table>

    <h3 style="margin-top:15px">ディスク追加</h3>
    <div id="new-disks"></div>
    <button type="button" class="btn btn-primary btn-sm" onclick="addNewDisk()" style="margin-top:5px"><i class="fas fa-plus"></i> ディスク追加</button>

    <h3 style="margin-top:15px"><i class="fas fa-compact-disc"></i> ISO / CD-ROM</h3>
    <div id="iso-disks"></div>
    <div style="display:flex;gap:8px;align-items:end;margin-top:8px">
        <div class="form-group" style="flex:1;margin:0">
            <label>プールから選択</label>
            <select id="iso-pool-select">
                <option value="">-- 選択してください --</option>
            </select>
        </div>
        <button type="button" class="btn btn-primary btn-sm" onclick="addIsoFromPool()" style="margin-bottom:2px"><i class="fas fa-plus"></i> 追加</button>
    </div>
    <button type="button" class="btn btn-secondary btn-sm" onclick="addIsoDisk()" style="margin-top:8px"><i class="fas fa-keyboard"></i> 手動入力</button>
</div>

<div class="card">
    <div class="card-header">
        <h2><i class="fas fa-tv"></i> グラフィックス</h2>
    </div>
    <p style="color:var(--text-secondary);margin-bottom:12px;font-size:0.9em">VNCとSPICEを<strong>両方</strong>有効にできます</p>

    <h3>VNC</h3>
    <div class="form-row">
        <div class="form-group">
            <label>ポート</label>
            <input type="text" name="vnc_port" value="{{ vm_config.vnc_port }}">
        </div>
        <div class="form-group">
            <label>リッスンアドレス</label>
            <input type="text" name="vnc_listen" value="{{ vm_config.vnc_listen }}">
        </div>
    </div>

    <h3 style="margin-top:15px">SPICE</h3>
    <div class="checkbox-group">
        <input type="checkbox" name="spice_enabled" id="spice_enabled" {% if vm_config.spice_enabled %}checked{% endif %}>
        <label for="spice_enabled">SPICEを有効にする</label>
    </div>
    <div id="spice-options" style="{% if not vm_config.spice_enabled %}display:none{% endif %}">
        <div class="form-row">
            <div class="form-group">
                <label>ポート</label>
                <input type="text" name="spice_port" value="{{ vm_config.spice_port }}">
            </div>
            <div class="form-group">
                <label>TLSポート</label>
                <input type="text" name="spice_tls_port" value="">
            </div>
        </div>
        <div class="form-group">
            <label>リッスンアドレス</label>
            <input type="text" name="spice_listen" value="{{ vm_config.spice_listen }}">
        </div>
    </div>
</div>

<div class="card">
    <h2><i class="fas fa-tv"></i> ビデオ</h2>
    <div class="form-row">
        <div class="form-group">
            <label>ビデオモデル</label>
            <select name="video_model">
                <option value="">自動</option>
                <option value="virtio" {% if vm_config.video_model == 'virtio' %}selected{% endif %}>VirtIO GPU</option>
                <option value="qxl" {% if vm_config.video_model == 'qxl' %}selected{% endif %}>QXL</option>
                <option value="cirrus" {% if vm_config.video_model == 'cirrus' %}selected{% endif %}>cirrus</option>
                <option value="none" {% if vm_config.video_model == 'none' %}selected{% endif %}>デバイスなし</option>
            </select>
        </div>
    </div>
</div>

<div class="card">
    <div class="card-header">
        <h2><i class="fas fa-network-wired"></i> ネットワーク</h2>
    </div>
    <div class="form-row">
        <div class="form-group">
            <label>ネットワークタイプ</label>
            <select name="net_type" {% if is_active %}disabled{% endif %}>
                {% for n in devices.networks %}
                <option value="{{ n.type }}" selected>{{ n.type }}</option>
                {% endfor %}
                {% if not devices.networks %}
                <option value="network">Libvirt仮想ネットワーク</option>
                {% endif %}
            </select>
        </div>
        <div class="form-group">
            <label>ネットワークソース</label>
            <select name="net_source" {% if is_active %}disabled{% endif %}>
                {% for n in devices.networks %}
                <option value="{{ n.source_network }}" selected>{{ n.source_network }}</option>
                {% endfor %}
                {% for net in networks %}
                <option value="{{ net.name }}">{{ net.name }}</option>
                {% endfor %}
            </select>
        </div>
    </div>
    <div class="form-group">
        <label>ネットワークモデル</label>
        <select name="net_model" {% if is_active %}disabled{% endif %}>
            {% for n in devices.networks %}
            <option value="{{ n.model }}" selected>{{ n.model }}</option>
            {% endfor %}
            {% if not devices.networks %}
            <option value="virtio">virtio</option>
            {% endif %}
        </select>
    </div>
</div>

<div class="card">
    <div class="card-header">
        <h2><i class="fas fa-usb"></i> USBデバイスパースルー</h2>
    </div>

    {% if devices.hostdevs or devices.usb_hostdevs is defined and devices.usb_hostdevs %}
    <h3>現在接続中のホストデバイス</h3>
    <table>
        <thead><tr><th>タイプ</th><th>詳細</th></tr></thead>
        <tbody>
        {% for h in devices.hostdevs %}
        <tr><td>PCI</td><td>{{ h.domain }}:{{ h.bus }}:{{ h.slot }}.{{ h.function }}</td></tr>
        {% endfor %}
        {% for u in devices.usb_hostdevs %}
        <tr><td>USB</td><td>{{ u.vendor_id }}:{{ u.product_id }}</td></tr>
        {% endfor %}
        </tbody>
    </table>
    {% endif %}

    <h3 style="margin-top:15px">USBデバイス追加</h3>
    <p style="color:var(--text-secondary);font-size:0.85em;margin-bottom:10px">ホストに接続されているUSBデバイスを選択してパースルーします</p>
    <div id="usb-devices-list">
        {% for usb in usb_devices %}
        <div class="checkbox-group">
            <input type="checkbox" name="usb_device" value='{{ {"vendor_id": usb.vendor_id, "product_id": usb.product_id} | tojson }}'>
            <label>{{ usb.label }}</label>
        </div>
        {% endfor %}
        {% if not usb_devices %}
        <p style="color:var(--text-secondary)">USBデバイスが検出されませんでした</p>
        {% endif %}
    </div>
</div>

<div class="card">
    <div class="card-header">
        <h2><i class="fas fa-volume-up"></i> サウンド / チャンネル / USBリダイレクター</h2>
    </div>
    <div class="checkbox-group">
        <input type="checkbox" name="sound_enabled" id="sound_enabled" {% if vm_config.sound_enabled %}checked{% endif %} {% if is_active %}disabled{% endif %}>
        <label for="sound_enabled">サウンドデバイス (ich9)</label>
        <small style="margin-left:5px;color:var(--text-secondary)">SPICEオーディオに使用</small>
    </div>
    <div class="checkbox-group">
        <input type="checkbox" name="channel_spice" id="channel_spice" {% if vm_config.channel_spice %}checked{% endif %} {% if is_active %}disabled{% endif %}>
        <label for="channel_spice">SPICE Agent チャンネル</label>
        <small style="margin-left:5px;color:var(--text-secondary)">クリップボード共有・モニタ自動調整に必要</small>
    </div>
    <div class="checkbox-group">
        <input type="checkbox" name="usb_redirector_1" id="usb_redirector_1" {% if vm_config.usb_redirector_1 %}checked{% endif %} {% if is_active %}disabled{% endif %}>
        <label for="usb_redirector_1">USB リダイレクター 1</label>
        <small style="margin-left:5px;color:var(--text-secondary)">SPICE経由でホストUSBを転送</small>
    </div>
    <div class="checkbox-group">
        <input type="checkbox" name="usb_redirector_2" id="usb_redirector_2" {% if vm_config.usb_redirector_2 %}checked{% endif %} {% if is_active %}disabled{% endif %}>
        <label for="usb_redirector_2">USB リダイレクター 2</label>
        <small style="margin-left:5px;color:var(--text-secondary)">2台目のUSB転送用</small>
    </div>
</div>

<div style="text-align:right;margin-top:20px;padding-bottom:30px">
    <button type="button" class="btn btn-secondary" onclick="window.history.back()" style="margin-right:10px">キャンセル</button>
    <button type="submit" class="btn btn-primary" style="font-size:1em;padding:12px 30px" {% if is_active %}disabled{% endif %}>
        <i class="fas fa-save"></i> 設定を保存
    </button>
</div>

</form>
{% endblock %}

{% block scripts %}
<script>
let newDiskCount = 0;
let isoDiskCount = 0;

document.getElementById('spice_enabled').addEventListener('change', function() {
    document.getElementById('spice-options').style.display = this.checked ? 'block' : 'none';
});

function addNewDisk() {
    newDiskCount++;
    const html = `
    <div class="disk-item" id="new-disk-${newDiskCount}">
        <button type="button" class="remove-btn" onclick="this.parentElement.remove()"><i class="fas fa-times"></i></button>
        <div class="form-row-3">
            <div class="form-group">
                <label>タイプ</label>
                <select name="new_type_${newDiskCount}" onchange="updateDiskDefaults(this, ${newDiskCount})">
                    <option value="file">ファイル (qcow2/raw)</option>
                    <option value="block">ブロックデバイス</option>
                </select>
            </div>
            <div class="form-group">
                <label>ソースパス</label>
                <input type="text" name="new_source_${newDiskCount}" placeholder="/dev/sdb">
            </div>
            <div class="form-group">
                <label>ターゲット</label>
                <input type="text" name="new_target_${newDiskCount}" value="vdb">
            </div>
        </div>
        <div class="form-row">
            <div class="form-group">
                <label>バス</label>
                <select name="new_bus_${newDiskCount}">
                    <option value="virtio">virtio</option>
                    <option value="sata">SATA</option>
                    <option value="scsi">SCSI</option>
                </select>
            </div>
            <div class="form-group">
                <label>ドライバー</label>
                <select name="new_driver_${newDiskCount}">
                    <option value="qcow2">qcow2</option>
                    <option value="raw">raw</option>
                </select>
            </div>
        </div>
    </div>`;
    document.getElementById('new-disks').insertAdjacentHTML('beforeend', html);
}

function updateDiskDefaults(sel, idx) {
    const item = sel.closest('.disk-item');
    const target = item.querySelector(`[name="new_target_${idx}"]`);
    const bus = item.querySelector(`[name="new_bus_${idx}"]`);
    const driver = item.querySelector(`[name="new_driver_${idx}"]`);
    if (sel.value === 'block') {
        target.value = 'vdb';
        bus.value = 'virtio';
        driver.value = 'raw';
    } else {
        target.value = 'vdb';
        bus.value = 'virtio';
        driver.value = 'qcow2';
    }
}

function addIsoDisk() {
    isoDiskCount++;
    const html = `
    <div class="disk-item" id="iso-disk-${isoDiskCount}">
        <button type="button" class="remove-btn" onclick="this.parentElement.remove()"><i class="fas fa-times"></i></button>
        <div class="form-row">
            <div class="form-group">
                <label>ISOファイルパス</label>
                <input type="text" name="iso_path_${isoDiskCount}" placeholder="/var/lib/libvirt/images/win11.iso">
            </div>
        </div>
    </div>`;
    document.getElementById('iso-disks').insertAdjacentHTML('beforeend', html);
}

function addIsoFromPool() {
    const sel = document.getElementById('iso-pool-select');
    if (!sel || !sel.value) { alert('ISOファイルを選択してください'); return; }
    isoDiskCount++;
    const html = `
    <div class="disk-item" id="iso-disk-${isoDiskCount}">
        <button type="button" class="remove-btn" onclick="this.parentElement.remove()"><i class="fas fa-times"></i></button>
        <div class="form-row">
            <div class="form-group">
                <label>ISOファイルパス</label>
                <input type="text" name="iso_path_${isoDiskCount}" value="${sel.value}" readonly>
            </div>
        </div>
    </div>`;
    document.getElementById('iso-disks').insertAdjacentHTML('beforeend', html);
    sel.value = '';
}

function loadIsoFiles() {
    const sel = document.getElementById('iso-pool-select');
    if (!sel) return;
    fetch('/api/iso-files').then(r=>r.json()).then(isos => {
        isos.sort((a,b) => a.name.localeCompare(b.name));
        isos.forEach(iso => {
            const opt = document.createElement('option');
            opt.value = iso.path;
            opt.textContent = iso.label;
            sel.appendChild(opt);
        });
    });
}

function collectFormData() {
    const fd = new FormData(document.getElementById('edit-form'));
    const data = {
        domain_type: fd.get('domain_type'),
        arch: '{{ vm_config.arch }}',
        machine: fd.get('machine'),
        vcpus: fd.get('vcpus'),
        memory_mb: fd.get('memory_mb'),
        uefi: fd.has('uefi'),
        vnc_port: fd.get('vnc_port'),
        vnc_listen: fd.get('vnc_listen'),
        spice_enabled: fd.has('spice_enabled'),
        spice_port: fd.get('spice_port'),
        spice_tls_port: fd.get('spice_tls_port'),
        spice_listen: fd.get('spice_listen'),
        video_model: fd.get('video_model'),
        tpm_enabled: fd.has('tpm_enabled'),
        secure_boot: fd.has('secure_boot'),
        sound_enabled: fd.has('sound_enabled'),
        channel_spice: fd.has('channel_spice'),
        usb_redirector_1: fd.has('usb_redirector_1'),
        usb_redirector_2: fd.has('usb_redirector_2'),
        net_type: fd.get('net_type'),
        net_source: fd.get('net_source'),
        net_model: fd.get('net_model'),
        existing_disks: [
            {% for d in devices.disks %}
            {
                type: '{{ d.type }}',
                device: '{{ d.device }}',
                target_dev: '{{ d.target_dev }}',
                target_bus: '{{ d.target_bus }}',
                source_file: '{{ d.source_file }}',
                source_dev: '{{ d.source_dev }}',
                source_name: '{{ d.source_name }}',
                source_protocol: '{{ d.source_protocol }}',
                driver_type: '{{ d.driver_type }}',
                readonly: {{ 'true' if d.device == 'cdrom' else 'false' }}
            },
            {% endfor %}
        ],
        existing_usbs: [
            {% for u in devices.usb_hostdevs %}
            {
                vendor_id: '{{ u.vendor_id }}',
                product_id: '{{ u.product_id }}'
            },
            {% endfor %}
        ],
        disks: [],
        iso_paths: [],
        hostdevs: [],
        usb_hostdevs: []
    };

    for (let i = 1; i <= 50; i++) {
        const src = fd.get('new_source_' + i);
        if (src) {
            const dtype = fd.get('new_type_' + i);
            data.disks.push({
                type: dtype,
                source_file: dtype === 'file' ? src : '',
                source_dev: dtype === 'block' ? src : '',
                target_dev: fd.get('new_target_' + i),
                target_bus: fd.get('new_bus_' + i),
                driver_type: fd.get('new_driver_' + i)
            });
        }
    }

    for (let i = 1; i <= 50; i++) {
        const src = fd.get('iso_path_' + i);
        if (src && src.trim()) {
            data.iso_paths.push(src.trim());
        }
    }

    document.querySelectorAll('[name="usb_device"]:checked').forEach(cb => {
        try {
            data.usb_hostdevs.push(JSON.parse(cb.value));
        } catch(e) {}
    });

    return data;
}

document.getElementById('edit-form').addEventListener('submit', function(e) {
    e.preventDefault();
    const data = collectFormData();

    if (!confirm('設定を保存しますか?\n(VMの定義が更新されます)')) return;

    fetch('/vm/{{ vm.name }}/edit', {
        method: 'POST',
        headers: {'Content-Type': 'application/json'},
        body: JSON.stringify(data)
    })
    .then(r => r.json())
    .then(result => {
        if (result.error) {
            alert('エラー: ' + result.error);
        } else {
            alert('設定を保存しました');
            window.location.href = '/vm/{{ vm.name }}';
        }
    })
    .catch(err => alert('通信エラー: ' + err));
});

document.addEventListener('DOMContentLoaded', function() {
    loadIsoFiles();
});
</script>
{% endblock %}
ENDOFFILE_VM_EDIT.HTML

cat << 'ENDOFFILE_STYLE_CSS' > /opt/vm-manage/static/css/style.css
:root {
    --bg-dark: #1a1a2e;
    --bg-card: #16213e;
    --bg-sidebar: #0f3460;
    --accent: #e94560;
    --accent-hover: #ff6b81;
    --text-primary: #eaeaea;
    --text-secondary: #a0a0b0;
    --border: #2a2a4a;
    --success: #2ecc71;
    --warning: #f39c12;
    --danger: #e74c3c;
    --info: #3498db;
}

* { margin: 0; padding: 0; box-sizing: border-box; }

body {
    font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
    background: var(--bg-dark);
    color: var(--text-primary);
    display: flex;
    min-height: 100vh;
}

.sidebar {
    width: 240px;
    background: var(--bg-sidebar);
    border-right: 1px solid var(--border);
    position: fixed;
    height: 100vh;
    overflow-y: auto;
}

.sidebar-header {
    padding: 20px;
    font-size: 1.3em;
    font-weight: bold;
    border-bottom: 1px solid var(--border);
    display: flex;
    align-items: center;
    gap: 10px;
}

.sidebar-header i { color: var(--accent); }

.sidebar-menu {
    list-style: none;
    padding: 10px 0;
}

.sidebar-menu li a {
    display: flex;
    align-items: center;
    gap: 10px;
    padding: 12px 20px;
    color: var(--text-secondary);
    text-decoration: none;
    transition: all 0.2s;
}

.sidebar-menu li a:hover,
.sidebar-menu li a.active {
    background: rgba(233, 69, 96, 0.15);
    color: var(--text-primary);
    border-right: 3px solid var(--accent);
}

.content {
    margin-left: 240px;
    padding: 30px;
    flex: 1;
    min-width: 0;
}

h1 { font-size: 1.8em; margin-bottom: 20px; }
h2 { font-size: 1.4em; margin-bottom: 15px; color: var(--text-primary); }
h3 { font-size: 1.1em; margin-bottom: 10px; color: var(--text-secondary); }

.alert {
    padding: 12px 18px;
    border-radius: 6px;
    margin-bottom: 20px;
    font-size: 0.95em;
}
.alert-error { background: rgba(231, 76, 60, 0.2); border: 1px solid var(--danger); color: var(--danger); }
.alert-success { background: rgba(46, 204, 113, 0.2); border: 1px solid var(--success); color: var(--success); }
.alert-warning { background: rgba(243, 156, 18, 0.2); border: 1px solid var(--warning); color: var(--warning); }

.card {
    background: var(--bg-card);
    border: 1px solid var(--border);
    border-radius: 8px;
    padding: 20px;
    margin-bottom: 20px;
}

.card-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-bottom: 15px;
    padding-bottom: 10px;
    border-bottom: 1px solid var(--border);
}

.card-header h2 { margin: 0; }

.grid { display: grid; gap: 20px; }
.grid-2 { grid-template-columns: repeat(2, 1fr); }
.grid-3 { grid-template-columns: repeat(3, 1fr); }

.vm-card {
    background: var(--bg-card);
    border: 1px solid var(--border);
    border-radius: 8px;
    padding: 20px;
    transition: transform 0.2s, border-color 0.2s;
}

.vm-card:hover {
    transform: translateY(-2px);
    border-color: var(--accent);
}

.vm-card-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-bottom: 15px;
}

.vm-card-header h3 { margin: 0; font-size: 1.2em; }

.status-badge {
    display: inline-block;
    padding: 3px 10px;
    border-radius: 12px;
    font-size: 0.8em;
    font-weight: bold;
    text-transform: uppercase;
}

.status-running { background: rgba(46, 204, 113, 0.2); color: var(--success); }
.status-stopped { background: rgba(160, 160, 176, 0.2); color: var(--text-secondary); }

.vm-info { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 15px; }
.vm-info dt { color: var(--text-secondary); font-size: 0.85em; }
.vm-info dd { font-weight: 500; }

.btn {
    display: inline-flex;
    align-items: center;
    gap: 6px;
    padding: 8px 16px;
    border: none;
    border-radius: 6px;
    cursor: pointer;
    font-size: 0.9em;
    font-weight: 500;
    transition: background 0.2s;
    text-decoration: none;
}

.btn-primary { background: var(--accent); color: white; }
.btn-primary:hover { background: var(--accent-hover); }
.btn-success { background: var(--success); color: white; }
.btn-success:hover { background: #27ae60; }
.btn-warning { background: var(--warning); color: white; }
.btn-warning:hover { background: #e67e22; }
.btn-danger { background: var(--danger); color: white; }
.btn-danger:hover { background: #c0392b; }
.btn-info { background: var(--info); color: white; }
.btn-info:hover { background: #2980b9; }
.btn-secondary { background: var(--border); color: var(--text-primary); }
.btn-secondary:hover { background: #3a3a5a; }

.btn-sm { padding: 5px 10px; font-size: 0.8em; }
.btn-group { display: flex; gap: 8px; flex-wrap: wrap; }

table {
    width: 100%;
    border-collapse: collapse;
    font-size: 0.9em;
}

th, td {
    text-align: left;
    padding: 10px 14px;
    border-bottom: 1px solid var(--border);
}

th {
    color: var(--text-secondary);
    font-weight: 600;
    font-size: 0.85em;
    text-transform: uppercase;
}

tr:hover { background: rgba(255, 255, 255, 0.03); }

.form-group { margin-bottom: 18px; }
.form-group label {
    display: block;
    margin-bottom: 6px;
    font-weight: 500;
    color: var(--text-secondary);
    font-size: 0.9em;
}

.form-group input,
.form-group select,
.form-group textarea {
    width: 100%;
    padding: 10px 14px;
    background: var(--bg-dark);
    border: 1px solid var(--border);
    border-radius: 6px;
    color: var(--text-primary);
    font-size: 0.95em;
}

.form-group input:focus,
.form-group select:focus,
.form-group textarea:focus {
    outline: none;
    border-color: var(--accent);
}

.form-group small {
    display: block;
    margin-top: 4px;
    color: var(--text-secondary);
    font-size: 0.8em;
}

.form-row {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 15px;
}

.form-row-3 {
    display: grid;
    grid-template-columns: 1fr 1fr 1fr;
    gap: 15px;
}

.checkbox-group {
    display: flex;
    align-items: center;
    gap: 8px;
    margin-top: 6px;
}

.checkbox-group input[type="checkbox"] {
    width: auto;
}

.tab-nav {
    display: flex;
    gap: 0;
    border-bottom: 2px solid var(--border);
    margin-bottom: 20px;
}

.tab-nav button {
    padding: 10px 20px;
    background: none;
    border: none;
    color: var(--text-secondary);
    cursor: pointer;
    font-size: 0.95em;
    border-bottom: 2px solid transparent;
    margin-bottom: -2px;
    transition: all 0.2s;
}

.tab-nav button.active {
    color: var(--accent);
    border-bottom-color: var(--accent);
}

.tab-panel { display: none; }
.tab-panel.active { display: block; }

.xml-editor {
    width: 100%;
    min-height: 400px;
    background: #0d1117;
    color: #c9d1d9;
    border: 1px solid var(--border);
    border-radius: 6px;
    padding: 15px;
    font-family: 'Fira Code', 'Consolas', monospace;
    font-size: 0.85em;
    line-height: 1.5;
    resize: vertical;
    tab-size: 2;
}

.disk-item {
    background: var(--bg-dark);
    border: 1px solid var(--border);
    border-radius: 6px;
    padding: 15px;
    margin-bottom: 10px;
    position: relative;
}

.disk-item .remove-btn {
    position: absolute;
    top: 10px;
    right: 10px;
    background: none;
    border: none;
    color: var(--danger);
    cursor: pointer;
    font-size: 1.1em;
}

@media (max-width: 900px) {
    .sidebar { width: 60px; }
    .sidebar-header span, .sidebar-menu li a span { display: none; }
    .sidebar-menu li a { justify-content: center; padding: 12px; }
    .content { margin-left: 60px; }
    .grid-2, .grid-3 { grid-template-columns: 1fr; }
    .form-row, .form-row-3 { grid-template-columns: 1fr; }
}

@keyframes spin { to { transform: rotate(360deg); } }
ENDOFFILE_STYLE_CSS

cat << 'ENDOFFILE_APP_JS' > /opt/vm-manage/static/js/app.js
document.addEventListener('DOMContentLoaded', function() {
    document.querySelectorAll('textarea').forEach(ta => {
        ta.addEventListener('keydown', function(e) {
            if (e.key === 'Tab') {
                e.preventDefault();
                const start = this.selectionStart;
                const end = this.selectionEnd;
                this.value = this.value.substring(0, start) + '  ' + this.value.substring(end);
                this.selectionStart = this.selectionEnd = start + 2;
            }
        });
    });
});
ENDOFFILE_APP_JS

echo "Creating venv..."
cd /opt/vm-manage
python3 -m venv --system-site-packages venv
/opt/vm-manage/venv/bin/pip install -q flask

sed -i "s/port=8090/port=${PORT}/" /opt/vm-manage/app.py

cat << ENDOFFILE_SERVICE > /etc/systemd/system/vm-manage.service
[Unit]
Description=VM Manager Web UI
After=libvirtd.service
Requires=libvirtd.service

[Service]
Type=simple
User=root
WorkingDirectory=/opt/vm-manage
ExecStart=/opt/vm-manage/venv/bin/python /opt/vm-manage/app.py
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target
ENDOFFILE_SERVICE

systemctl daemon-reload
systemctl enable vm-manage
systemctl enable libvirtd 2>/dev/null || true
systemctl start libvirtd 2>/dev/null || true
systemctl restart vm-manage

echo ""
echo "========================================="
echo " VM Manager installed successfully!"
echo " Port: ${PORT}"
echo " URL:  http://$(hostname -I | awk '{print $1}'):${PORT}"
echo "========================================="

アンインストール方法

sudo systemctl stop vm-manage
sudo systemctl disable vm-manage
sudo rm /etc/systemd/system/vm-manage.service
sudo systemctl daemon-reload
sudo rm -rf /opt/vm-manage

これでサービス停止・削除・インストールディレクトリの削除が完了します。
libvirt、QEMU、noVNC等の依存パッケージはスクリプトでインストールされていますが、他のVM関連ツール(Cockpit等)でも使う可能性があるため、上記には含めていません。
もし不要であれば個別に apt remove してください。

タイトルとURLをコピーしました