QEMU/KVMの仮想マシン作成・編集の補助ツール

Webブラウザから仮想マシンを扱うならば、Cockpit+仮想マシンプラグインが便利ですが、インストールして使うvirt-managerに比べて設定項目が少なかったりします。それを補完するようなツールを作成してみました。
おもに作成と編集に役立ちます。実際の運用はCockpit+仮想マシンプラグインで行ってください。

ホストにインストール

VM操作に利用するのでホストにインストールします。LAN内からアクセスが可能になるので何かしらで対策を。
また、まだ完全じゃないので、使っていきながら少しずつ修正する予定です。

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

INSTALL_DIR="/opt/vm-manage"
SERVICE_NAME="vm-manage"
PORT=8090

if [ "$(id -u)" -ne 0 ]; then
    echo "このスクリプトはroot権限で実行してください"
    exit 1
fi

echo "=== VM Manager Web UI インストール ==="

echo "[1/6] システムパッケージをインストール中..."
apt-get update -qq
apt-get install -y -qq python3 python3-venv python3-libvirt libvirt-daemon-system libvirt-clients qemu-system-x86 ovmf usbutils > /dev/null

echo "[2/6] インストールディレクトリを作成中..."
mkdir -p "$INSTALL_DIR"/{templates,static/css,static/js,venv}

echo "[3/6] Python仮想環境を作成中..."
python3 -m venv --system-site-packages "$INSTALL_DIR/venv"
"$INSTALL_DIR/venv/bin/pip" install -q flask

echo "[4/6] アプリケーションファイルを配置中..."

# --- app.py ---
cat > "$INSTALL_DIR/app.py" << 'APPEOF'
#!/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"


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,
        "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",
    }
    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

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

    try:
        import subprocess
        subprocess.run(
            ["sudo", "virsh", "undefine", name, "--nvram"],
            capture_output=True, timeout=10, check=True
        )
        conn.defineXML(new_xml)
        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", [])

    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>")
    if uefi:
        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>")
        lines.append("    <boot dev='hd'/>")
        lines.append("    <bootmenu enable='yes'/>")
    else:
        lines.append("  <os>")
        lines.append(f"    <type arch='{arch}' machine='{machine}'>hvm</type>")
        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 == "lun":
            lines.append("    <disk type='network' device='lun'>")
            lines.append("      <driver name='qemu' type='raw'/>")
            lines.append(f"      <source protocol='iscsi' name='{nd.get('source_name', '')}'/>")
            lines.append(f"      <target dev='{nd.get('target_dev', 'sdb')}' bus='scsi'/>")
            lines.append("    </disk>")
        elif 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 iso.strip():
            dev = 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.strip()}'/>")
            lines.append(f"      <target dev='sd{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>")

    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>")

    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:
                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)}
        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:
                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)}
        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
                    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:
            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)
    boot_order = config.get("boot_order", [])
    if uefi:
        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>")
        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 iso.strip():
            dev = 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.strip()}'/>")
            lines.append(f"      <target dev='sd{dev}' bus='sata'/>")
            lines.append("      <readonly/>")
            lines.append("    </disk>")
            iso_idx += 1

    for dc in disks_config:
        dtype = dc.get("type", "")
        if dtype == "lun":
            lines.append("    <disk type='network' device='lun'>")
            lines.append("      <driver name='qemu' type='raw'/>")
            source_name = dc.get("source_name", "")
            lines.append(f"      <source protocol='iscsi' name='{source_name}'/>")
            target_dev = dc.get("target_dev", f"sd{dev_letters[dev_idx]}")
            lines.append(f"      <target dev='{target_dev}' bus='scsi'/>")
            lines.append("      <address type='drive' controller='0' bus='0' target='0' unit='0'/>")
            lines.append("    </disk>")
        elif 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/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)


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

# --- templates/base.html ---
cat > "$INSTALL_DIR/templates/base.html" << 'BASEEOF'
<!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="/" class="{% if request.endpoint == 'index' %}active{% endif %}"><i class="fas fa-list"></i> 一覧</a></li>
            <li><a href="/vm/create" class="{% if request.endpoint == 'vm_create' %}active{% endif %}"><i class="fas fa-plus-circle"></i> 新規作成</a></li>
        </ul>
    </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>
BASEEOF

# --- templates/index.html ---
cat > "$INSTALL_DIR/templates/index.html" << 'INDEXEOF'
{% 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>
                <a href="/vm/{{ vm.name }}" class="btn btn-info btn-sm"><i class="fas fa-edit"></i> 編集</a>
            {% 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 %}
        </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 %}
INDEXEOF

# --- templates/vm_create.html ---
cat > "$INSTALL_DIR/templates/vm_create.html" << 'CREATEEOF'
{% 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同時有効化、LUN/ブロックpassthrough、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>
            <input type="number" name="vcpus" value="2" min="1" max="256">
        </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>
        <label for="uefi">UEFIブート (OVMF) を有効にする</label>
        <small style="margin-left:5px;color:var(--text-secondary)">Windows 11等で必須</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) - 空なら作成しない</label>
            <input type="number" name="disk_size_gb" value="50" min="0" placeholder="例: 50">
        </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>
    <button type="button" class="btn btn-primary btn-sm" onclick="addExtraDisk()" style="margin-top:5px"><i class="fas fa-plus"></i> ファイル/ブロックディスク追加</button>

    <h3 style="margin-top:15px">iSCSI / LUN パススルー</h3>
    <div id="lun-disks"></div>
    <button type="button" class="btn btn-primary btn-sm" onclick="addLunDisk()" style="margin-top:5px"><i class="fas fa-plus"></i> LUN追加</button>
    <p style="font-size:0.8em;color:var(--text-secondary);margin-top:5px">例: iqn.2024-01.com.example:lun01@192.168.1.100:3260</p>

    <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-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 lunDiskCount = 0;
let isoDiskCount = 0;
let bootDevCount = 1;

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 html = `
    <div class="disk-item" id="extra-disk-${extraDiskCount}">
        <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="extra_type_${extraDiskCount}">
                    <option value="file">ファイル (qcow2/raw)</option>
                    <option value="block">ブロックデバイス</option>
                </select>
            </div>
            <div class="form-group">
                <label>ソースパス</label>
                <input type="text" name="extra_source_${extraDiskCount}" placeholder="/var/lib/libvirt/images/disk2.qcow2 または /dev/sdb">
            </div>
            <div class="form-group">
                <label>ターゲット</label>
                <input type="text" name="extra_target_${extraDiskCount}" value="vdb">
            </div>
        </div>
        <div class="form-row">
            <div class="form-group">
                <label>バス</label>
                <select name="extra_bus_${extraDiskCount}">
                    <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="extra_driver_${extraDiskCount}">
                    <option value="qcow2">qcow2</option>
                    <option value="raw">raw</option>
                </select>
            </div>
        </div>
    </div>`;
    document.getElementById('extra-disks').insertAdjacentHTML('beforeend', html);
}

function addLunDisk() {
    lunDiskCount++;
    const html = `
    <div class="disk-item" id="lun-disk-${lunDiskCount}">
        <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>iSCSIターゲット (name@host:port)</label>
                <input type="text" name="lun_source_${lunDiskCount}" placeholder="iqn.xxx:lun01@192.168.1.100:3260">
            </div>
            <div class="form-group">
                <label>ターゲットデバイス名</label>
                <input type="text" name="lun_target_${lunDiskCount}" value="sda" placeholder="sda">
            </div>
        </div>
    </div>`;
    document.getElementById('lun-disks').insertAdjacentHTML('beforeend', html);
}

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; }
    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('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'),
        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) {
            data.disks.push({
                type: fd.get('extra_type_' + i),
                source_file: fd.get('extra_type_' + i) === 'file' ? src : '',
                source_dev: fd.get('extra_type_' + i) === '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('lun_source_' + i);
        if (src) {
            data.disks.push({
                type: 'lun',
                source_name: src,
                target_dev: fd.get('lun_target_' + 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^="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 === 'lun') {
            xml += `    <disk type='network' device='lun'>\n`;
            xml += `      <driver name='qemu' type='raw'/>\n`;
            xml += `      <source protocol='iscsi' name='${dc.source_name}'/>\n`;
            xml += `      <target dev='${dc.target_dev}' bus='scsi'/>\n`;
            xml += `    </disk>\n`;
        } else 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 dev = String.fromCharCode(99 + isoIdx);
        xml += `    <disk type='file' device='cdrom'>\n`;
        xml += `      <driver name='qemu' type='raw'/>\n`;
        xml += `      <source file='${iso}'/>\n`;
        xml += `      <target dev='sd${dev}' 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();
});
</script>
{% endblock %}
CREATEEOF

# --- templates/vm_detail.html ---
cat > "$INSTALL_DIR/templates/vm_detail.html" << 'DETAILEOF'
{% 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>

<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>
            <input type="number" name="vcpus" value="{{ vm.vcpus }}" min="1" {% if is_active %}disabled{% endif %}>
        </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>

<div class="card">
    <h2><i class="fas fa-hdd"></i> ディスク</h2>
    {% if devices.disks %}
    <table>
        <thead><tr><th>デバイス</th><th>タイプ</th><th>ソース</th><th>バス</th></tr></thead>
        <tbody>
        {% for d in devices.disks %}
        <tr>
            <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>
        </tr>
        {% endfor %}
        </tbody>
    </table>
    {% else %}
    <p style="color:var(--text-secondary)">ディスクがありません</p>
    {% endif %}

    {% if not is_active %}
    <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>
    {% endif %}

    <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 }}" {% 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 %}
        </select>
    </div>
</div>

<div class="card">
    <div class="card-header">
        <h2><i class="fas fa-usb"></i> USBデバイス</h2>
    </div>
    <div id="usb-attached-list"></div>
    {% if is_active %}
    <h3 style="margin-top:10px">ホストUSBデバイス(ホットプラグ)</h3>
    <p style="color:var(--text-secondary);font-size:0.85em">稼働中にVMに接続・取り外しできます</p>
    <div id="usb-host-list"></div>
    {% endif %}
</div>

<div class="card">
    <div class="card-header">
        <h2><i class="fas fa-sort-amount-down"></i> ブート順序</h2>
    </div>
    <p style="color:var(--text-secondary);font-size:0.85em;margin-bottom:10px">{% if is_active %}停止後に変更可能{% else %}矢印で順序を変更{% endif %}</p>
    <div id="boot-order-list"></div>
    {% if not is_active %}
    <button type="button" class="btn btn-primary btn-sm" onclick="saveBootOrder()" style="margin-top:10px"><i class="fas fa-save"></i> ブート順序を保存</button>
    {% endif %}
</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">
    <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 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}">
                    <option value="file">ファイル</option>
                    <option value="block">ブロックデバイス</option>
                    <option value="lun">iSCSI LUN</option>
                </select>
            </div>
            <div class="form-group">
                <label>ソースパス</label>
                <input type="text" name="new_source_${newDiskCount}" placeholder="/path/to/disk.qcow2">
            </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 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);
        });
    });
}

function collectFormData() {
    const fd = new FormData(document.getElementById('vm-form'));
    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'),
        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'),
        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: []
    };
}

document.getElementById('vm-form').addEventListener('submit', function(e) {
    e.preventDefault();
    const data = collectFormData();
    for (let i = 1; i <= 50; i++) {
        const src = document.querySelector(`[name="new_source_${i}"]`);
        if (src && src.value) {
            const dtype = document.querySelector(`[name="new_type_${i}"]`).value;
            data.disks.push({
                type: dtype,
                source_file: dtype === 'file' ? src.value : '',
                source_dev: dtype === 'block' ? src.value : '',
                source_name: dtype === 'lun' ? src.value : '',
                target_dev: document.querySelector(`[name="new_target_${i}"]`).value,
                target_bus: document.querySelector(`[name="new_bus_${i}"]`).value,
                driver_type: document.querySelector(`[name="new_driver_${i}"]`).value
            });
        }
    }
    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 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' }};
    fetch(`/api/vm/{{ vm.name }}/xml`).then(r=>r.json()).then(data => {
        const doc = new DOMParser().parseFromString(data.xml, 'text/xml');
        const usbs = doc.querySelectorAll('hostdev[type="usb"]');
        let html = '';
        if (usbs.length) {
            html = '<table><thead><tr><th>Vendor:Product</th><th></th></tr></thead><tbody>';
            usbs.forEach(hd => {
                const v = hd.querySelector('vendor')?.getAttribute('id') || '';
                const p = hd.querySelector('product')?.getAttribute('id') || '';
                html += `<tr><td>${v}:${p}</td>`;
                if (isActive) 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;
    });
    if (isActive) {
        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>`;
                html += `<button type="button" class="btn btn-success btn-sm" onclick="usbAttach('${d.vendor_id}','${d.product_id}','${d.label}')"><i class="fas fa-plug"></i> 接続</button></div>`;
            });
            document.getElementById('usb-host-list').innerHTML = html || '<p style="color:var(--text-secondary)">USBデバイスなし</p>';
        });
    }
}

function loadBootOrder() {
    fetch(`/api/vm/{{ vm.name }}/bootorder`).then(r=>r.json()).then(data => {
        let html = '';
        if (data.boot_order && data.boot_order.length) {
            html = '<div id="boot-order-items">';
            const icons = {hd:'\uD83D\uDCBE',cdrom:'\uD83D\uDCBF',network:'\uD83C\uDF10'};
            data.boot_order.forEach((item, i) => {
                html += `<div class="disk-item" style="display:flex;align-items:center;gap:10px;padding:8px 12px" draggable="true" data-dev="${item.dev}" data-idx="${i}">`;
                html += `<span style="color:var(--text-secondary);font-weight:bold">${i+1}.</span>`;
                html += `<span>${icons[item.dev]||'\u2753'} ${item.dev}</span>`;
                if (i > 0) html += `<button type="button" class="btn btn-secondary btn-sm" onclick="moveBoot(${i},-1)"><i class="fas fa-arrow-up"></i></button>`;
                if (i < data.boot_order.length-1) html += `<button type="button" class="btn btn-secondary btn-sm" onclick="moveBoot(${i},1)"><i class="fas fa-arrow-down"></i></button>`;
                html += '</div>';
            });
            html += '</div>';
        } else {
            html = '<p style="color:var(--text-secondary)">ブート順序なし</p>';
        }
        document.getElementById('boot-order-list').innerHTML = html;
    }).catch(() => {
        document.getElementById('boot-order-list').innerHTML = '<p style="color:var(--text-secondary)">取得できません</p>';
    });
}

function moveBoot(idx, dir) {
    const c = document.getElementById('boot-order-items');
    if (!c) return;
    const items = [...c.children];
    const ni = idx + dir;
    if (ni < 0 || ni >= items.length) return;
    c.insertBefore(dir === -1 ? items[idx] : items[ni], dir === -1 ? items[ni] : items[idx]);
    items.forEach((it, i) => it.querySelector('span:first-child').textContent = (i+1)+'.');
}

function saveBootOrder() {
    const items = document.querySelectorAll('#boot-order-items .disk-item');
    const order = [...items].map(it => ({dev: it.dataset.dev}));
    fetch(`/api/vm/{{ vm.name }}/bootorder`, {
        method: 'PUT',
        headers: {'Content-Type': 'application/json'},
        body: JSON.stringify({boot_order: order})
    })
    .then(r => r.json())
    .then(d => { if (d.error) alert('エラー: ' + d.error); else { alert('保存しました'); loadBootOrder(); }})
    .catch(err => alert('通信エラー: ' + err));
}

document.addEventListener('DOMContentLoaded', function() {
    loadUsbDevices();
    loadBootOrder();
    loadIsoFiles();
});
</script>
{% endblock %}
DETAILEOF

# --- templates/vm_edit.html ---
cat > "$INSTALL_DIR/templates/vm_edit.html" << 'EDITEOF'
{% 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>
            <input type="number" name="vcpus" value="{{ vm_config.vcpus }}" min="1" {% if is_active %}disabled{% endif %}>
        </div>
        <div class="form-group">
            <label>メモリ (MB)</label>
            <input type="number" name="memory_mb" value="{{ vm_config.memory_mb }}" min="256" {% if is_active %}disabled{% endif %}>
        </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>

<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 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}">
                    <option value="file">ファイル (qcow2/raw)</option>
                    <option value="block">ブロックデバイス</option>
                    <option value="lun">iSCSI LUN</option>
                </select>
            </div>
            <div class="form-group">
                <label>ソースパス</label>
                <input type="text" name="new_source_${newDiskCount}" placeholder="/var/lib/libvirt/images/disk.qcow2">
            </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 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'),
        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 : '',
                source_name: dtype === 'lun' ? 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 %}
EDITEOF

# --- static/css/style.css ---
cat > "$INSTALL_DIR/static/css/style.css" << 'CSSEOF'
: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; }
}
CSSEOF

# --- static/js/app.js ---
cat > "$INSTALL_DIR/static/js/app.js" << 'JSEOF'
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;
            }
        });
    });
});
JSEOF

chmod +x "$INSTALL_DIR/app.py"

echo "[5/6] systemdサービスを設定中..."
cat > "/etc/systemd/system/${SERVICE_NAME}.service" << SVCEOF
[Unit]
Description=VM Manager Web UI
After=libvirtd.service
Requires=libvirtd.service

[Service]
Type=simple
User=root
WorkingDirectory=${INSTALL_DIR}
ExecStart=${INSTALL_DIR}/venv/bin/python ${INSTALL_DIR}/app.py
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target
SVCEOF

systemctl daemon-reload
systemctl enable "${SERVICE_NAME}.service"

echo "[6/6] サービスを起動中..."
systemctl restart "${SERVICE_NAME}.service"
sleep 1

if systemctl is-active --quiet "${SERVICE_NAME}.service"; then
    IP=$(hostname -I | awk '{print $1}')
    echo ""
    echo "=== インストール完了 ==="
    echo "VM Manager Web UI: http://${IP}:${PORT}"
    echo "サービス: systemctl status ${SERVICE_NAME}"
else
    echo ""
    echo "=== エラー: サービスの起動に失敗しました ==="
    systemctl status "${SERVICE_NAME}" --no-pager
    exit 1
fi

/ISOフォルダへのアクセス権でエラーが出た場合

/opt/vmのほか、/isoにあるISOファイルもソースにできますが、場合によってはアクセス権の問題で起動出来ないことがあります。その場合は下記でアクセス権を修正します。

nano vm-troubleshooting.sh
sudo bash vm-troubleshooting.sh
#!/bin/bash
# VM Manager Troubleshooting Script
# /iso ディレクトリへのQEMU/libvirt アクセス権限修正

set -e

echo "=== VM Manager Troubleshooting ==="
echo ""

if [ "$(id -u)" -ne 0 ]; then
    echo "このスクリプトはroot権限で実行してください"
    echo "使い方: sudo bash troubleshooting.sh"
    exit 1
fi

echo "[1/3] /isoディレクトリの権限を修正中..."
if [ -d "/iso" ]; then
    chown libvirt-qemu:kvm /iso
    chmod 755 /iso
    echo "  /iso: $(stat -c '%U:%G %a' /iso)"
else
    echo "  /iso ディレクトリが見つかりません"
fi

echo "[2/3] ISOファイルの権限を修正中..."
ISO_COUNT=0
for f in /iso/*.iso /iso/*.img /iso/*.raw /iso/*.qcow2; do
    if [ -f "$f" ]; then
        chown libvirt-qemu:kvm "$f"
        chmod 644 "$f"
        echo "  $(basename $f): $(stat -c '%U:%G %a' $f)"
        ISO_COUNT=$((ISO_COUNT + 1))
    fi
done
if [ "$ISO_COUNT" -eq 0 ]; then
    echo "  ISOファイルが見つかりません"
fi

echo "[3/3] AppArmor設定を更新中..."
LOCAL_FILE="/etc/apparmor.d/local/usr.lib.libvirt.qemu"
if [ -f "$LOCAL_FILE" ]; then
    if ! grep -q "/iso/\*\* rwk" "$LOCAL_FILE" 2>/dev/null; then
        echo "/iso/** rwk," >> "$LOCAL_FILE"
        echo "  AppArmorに /iso/** を追加しました"
    else
        echo "  AppArmorに /iso/** は既に設定済みです"
    fi
    systemctl reload apparmor 2>/dev/null || true
    echo "  AppArmorをリロードしました"
else
    echo "  AppArmor設定ファイルが見つかりません: $LOCAL_FILE"
fi

echo ""
echo "=== 完了 ==="
echo ""
echo "VMを再起動してください:"
echo "  sudo virsh start <VM名>"
echo ""
echo "一覧を確認:"
echo "  virsh list --all"
タイトルとURLをコピーしました