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"

