昨日作成したツールをある程度、実用的なところまで。
Cockpit仮想マシンプラグインの置き換えを目指してみました。

BIOS、UEFI、セキュアブートの切り替えがしやすいのと、USBパススルーなどがしやすいのかなと。
ホストにインストール
さらに新しいバージョンを作ったので、使うならそちらを。
LAN内の別のPCからアクセスされてしまうので何かしら対策しながら使ってみてください。
sudo nano install-vm-manage2.sh
sudo bash install-vm-manage2.sh
#!/bin/bash
set -e
# Root check
if [ "$(id -u)" -ne 0 ]; then
echo "Error: This script must be run as root." >&2
exit 1
fi
# Port prompt
read -rp "Enter port for VM Manager (default 8090): " PORT
PORT="${PORT:-8090}"
# Install dependencies
echo "Installing dependencies..."
apt-get update -qq
apt-get install -y -qq python3 python3-venv python3-pip libvirt-daemon-system libvirt-clients qemu-system-x86 qemu-utils ovmf novnc python3-websockify
# Create directories
mkdir -p /opt/vm-manage/templates
mkdir -p /opt/vm-manage/static/css
mkdir -p /opt/vm-manage/static/js
# Write app.py
cat << 'ENDOFFILE_APP_PY' > /opt/vm-manage/app.py
#!/usr/bin/env python3
import libvirt
import os
from xml.etree import ElementTree as ET
from flask import Flask, render_template, request, jsonify, redirect, url_for, flash
app = Flask(__name__)
app.secret_key = os.urandom(24)
LIBVIRT_URI = "qemu:///system"
@app.context_processor
def inject_vms():
try:
conn = get_conn()
vms = []
for dom_id in conn.listDomainsID():
dom = conn.lookupByID(dom_id)
vms.append(_vm_info(dom))
for name in conn.listDefinedDomains():
dom = conn.lookupByName(name)
vms.append(_vm_info(dom))
conn.close()
vms.sort(key=lambda v: v["name"].lower())
return {"sidebar_vms": vms}
except Exception:
return {"sidebar_vms": []}
def get_conn():
return libvirt.open(LIBVIRT_URI)
@app.route("/")
def index():
conn = get_conn()
vms = []
for dom_id in conn.listDomainsID():
dom = conn.lookupByID(dom_id)
vms.append(_vm_info(dom))
for name in conn.listDefinedDomains():
dom = conn.lookupByName(name)
vms.append(_vm_info(dom))
conn.close()
return render_template("index.html", vms=vms)
def _vm_info(dom):
info = dom.info()
xml_str = dom.XMLDesc(0)
root = ET.fromstring(xml_str)
os_el = root.find(".//os/type")
os_type_attr = os_el.get("type", "") if os_el is not None else ""
machine = os_el.get("machine", "") if os_el is not None else ""
return {
"id": dom.ID() if dom.isActive() else None,
"name": dom.name(),
"state": "running" if dom.isActive() else "stopped",
"vcpus": info[3],
"memory_mb": info[2] // 1024,
"domain_type": root.get("type", ""),
"machine": machine,
}
@app.route("/vm/<name>")
def vm_detail(name):
conn = get_conn()
try:
dom = conn.lookupByName(name)
except libvirt.libvirtError:
flash(f"VM '{name}' が見つかりません", "error")
conn.close()
return redirect(url_for("index"))
xml_str = dom.XMLDesc(0)
root = ET.fromstring(xml_str)
devices = _parse_devices(root)
is_active = dom.isActive()
os_el = root.find(".//os/type")
loader_el = root.find(".//os/loader")
video_el = root.find(".//video/model")
tpm_el = root.find(".//tpm")
os_info = {
"domain_type": root.get("type", ""),
"arch": os_el.get("arch", "") if os_el is not None else "",
"machine": os_el.get("machine", "") if os_el is not None else "",
}
vm_config = {
"uefi": loader_el is not None,
"secure_boot": False,
"tpm_enabled": tpm_el is not None,
"video_model": video_el.get("type", "") if video_el is not None else "",
"vnc_port": "-1",
"vnc_listen": "0.0.0.0",
"spice_enabled": False,
"spice_port": "-1",
"spice_listen": "0.0.0.0",
"sound_enabled": root.find(".//sound") is not None,
"channel_spice": root.find(".//channel[@type='spicevmc']") is not None,
"usb_redirector_1": False,
"usb_redirector_2": False,
}
firmware_el = root.find(".//firmware")
if firmware_el is not None:
for feat in firmware_el.findall("feature"):
if feat.get("name") == "secure-boot" and feat.get("enabled") == "yes":
vm_config["secure_boot"] = True
break
redir_count = 0
for rd in root.findall(".//redirdev"):
if rd.get("type") == "spicevmc":
redir_count += 1
if redir_count == 1:
vm_config["usb_redirector_1"] = True
elif redir_count == 2:
vm_config["usb_redirector_2"] = True
for g in devices["graphics"]:
if g["type"] == "vnc":
vm_config["vnc_port"] = g.get("port", "-1")
vm_config["vnc_listen"] = g.get("listen_address", g.get("listen", "0.0.0.0"))
elif g["type"] == "spice":
vm_config["spice_enabled"] = True
vm_config["spice_port"] = g.get("port", "-1")
vm_config["spice_listen"] = g.get("listen_address", g.get("listen", "0.0.0.0"))
networks = []
for nname in conn.listNetworks():
net = conn.networkLookupByName(nname)
networks.append({"name": nname, "active": net.isActive()})
conn.close()
return render_template(
"vm_detail.html",
vm=_vm_info(dom),
xml=xml_str,
devices=devices,
os_info=os_info,
vm_config=vm_config,
networks=networks,
is_active=is_active,
)
def _parse_devices(root):
devices = {"disks": [], "graphics": [], "networks": [], "hostdevs": []}
for disk in root.findall(".//disk"):
d = {
"type": disk.get("type", ""),
"device": disk.get("device", "disk"),
"target_dev": "",
"target_bus": "",
"source_file": "",
"source_dev": "",
"source_protocol": "",
"source_name": "",
"driver_type": "",
}
target = disk.find("target")
if target is not None:
d["target_dev"] = target.get("dev", "")
d["target_bus"] = target.get("bus", "")
source = disk.find("source")
if source is not None:
d["source_file"] = source.get("file", "")
d["source_dev"] = source.get("dev", "")
d["source_protocol"] = source.get("protocol", "")
d["source_name"] = source.get("name", "")
driver = disk.find("driver")
if driver is not None:
d["driver_type"] = driver.get("type", "")
devices["disks"].append(d)
for graphics in root.findall(".//graphics"):
g = {
"type": graphics.get("type", ""),
"port": graphics.get("port", ""),
"tlsPort": graphics.get("tlsPort", ""),
"autoport": graphics.get("autoport", ""),
"listen": graphics.get("listen", ""),
}
listen_el = graphics.find("listen")
if listen_el is not None:
g["listen_type"] = listen_el.get("type", "")
g["listen_address"] = listen_el.get("address", "")
devices["graphics"].append(g)
for iface in root.findall(".//interface"):
n = {
"type": iface.get("type", ""),
"mac": "",
"source_network": "",
"model": "",
}
mac = iface.find("mac")
if mac is not None:
n["mac"] = mac.get("address", "")
source = iface.find("source")
if source is not None:
n["source_network"] = source.get("network", "") or source.get("bridge", "")
model = iface.find("model")
if model is not None:
n["model"] = model.get("type", "")
devices["networks"].append(n)
for hostdev in root.findall(".//hostdev"):
h = {"type": hostdev.get("type", ""), "mode": hostdev.get("mode", "")}
source = hostdev.find("source")
if source is not None:
address = source.find("address")
if address is not None:
h["domain"] = address.get("domain", "")
h["bus"] = address.get("bus", "")
h["slot"] = address.get("slot", "")
h["function"] = address.get("function", "")
devices["hostdevs"].append(h)
for hostdev in root.findall(".//hostdev"):
if hostdev.get("type") == "usb":
uh = {"vendor_id": "", "product_id": ""}
source = hostdev.find("source")
if source is not None:
vendor = source.find("vendor")
product = source.find("product")
uh["vendor_id"] = vendor.get("id", "") if vendor is not None else ""
uh["product_id"] = product.get("id", "") if product is not None else ""
devices.setdefault("usb_hostdevs", []).append(uh)
return devices
def _get_usb_devices():
import subprocess
usb_devices = []
try:
result = subprocess.run(
["lsusb"], capture_output=True, text=True, timeout=5
)
for line in result.stdout.strip().split("\n"):
if not line.strip():
continue
parts = line.split()
if len(parts) < 6:
continue
id_str = parts[5]
if ":" not in id_str:
continue
vendor_id, product_id = id_str.split(":", 1)
name = " ".join(parts[6:])
bus = parts[1]
dev = parts[3].rstrip(":")
usb_devices.append({
"vendor_id": vendor_id,
"product_id": product_id,
"name": name,
"bus": bus,
"device": dev,
"label": f"{id_str} - {name} (Bus {bus}, Dev {dev})",
})
except Exception:
pass
return usb_devices
@app.route("/api/usb-devices")
def api_usb_devices():
return jsonify(_get_usb_devices())
@app.route("/vm/<name>/edit", methods=["GET", "POST"])
def vm_edit(name):
if request.method == "GET":
return redirect(url_for("vm_detail", name=name))
config = request.json
config["name"] = name
conn = get_conn()
try:
dom = conn.lookupByName(name)
except libvirt.libvirtError:
conn.close()
return jsonify({"error": f"VM '{name}' が見つかりません"}), 404
if dom.isActive():
conn.close()
return jsonify({"error": "VMを停止してから編集してください"}), 400
config["uuid"] = dom.UUIDString()
new_xml = _build_edit_xml(config)
if new_xml is None:
conn.close()
return jsonify({"error": "XMLの生成に失敗しました"}), 400
try:
import subprocess, tempfile
with tempfile.NamedTemporaryFile(mode='w', suffix='.xml', delete=False) as f:
f.write(new_xml)
tmp_path = f.name
r = subprocess.run(
["sudo", "virsh", "define", tmp_path],
capture_output=True, text=True, timeout=10
)
subprocess.run(["sudo", "rm", "-f", tmp_path], capture_output=True, timeout=5)
if r.returncode != 0:
conn.close()
return jsonify({"error": r.stderr.strip() or r.stdout.strip()}), 400
conn.close()
return jsonify({"success": True})
except Exception as e:
conn.close()
return jsonify({"error": str(e)}), 400
def _build_edit_xml(config):
name = config.get("name", "")
domain_type = config.get("domain_type", "kvm")
vcpus = int(config.get("vcpus", 2))
memory_mb = int(config.get("memory_mb", 4096))
memory_kb = memory_mb * 1024
arch = config.get("arch", "x86_64")
machine = config.get("machine", "pc-q35-10.2")
uefi = config.get("uefi", False)
vnc_port = config.get("vnc_port", "") or "-1"
try:
int(vnc_port)
except (ValueError, TypeError):
vnc_port = "-1"
vnc_listen = config.get("vnc_listen", "") or "0.0.0.0"
spice_enabled = config.get("spice_enabled", False)
spice_port = config.get("spice_port", "") or "-1"
try:
int(spice_port)
except (ValueError, TypeError):
spice_port = "-1"
spice_tls_port = config.get("spice_tls_port", "") or ""
spice_listen = config.get("spice_listen", "") or "0.0.0.0"
video_model = config.get("video_model", "")
if not video_model:
video_model = "qxl" if spice_enabled else "virtio"
tpm_enabled = config.get("tpm_enabled", False)
net_type = config.get("net_type", "network")
net_source = config.get("net_source", "default")
net_model = config.get("net_model", "virtio")
existing_disks = config.get("existing_disks", [])
new_disks = config.get("disks", [])
iso_paths = config.get("iso_paths", [])
hostdevs = config.get("hostdevs", [])
existing_usbs = config.get("existing_usbs", [])
usb_hostdevs = config.get("usb_hostdevs", [])
boot_order = config.get("boot_order", [])
lines = []
lines.append(f'<domain type="{domain_type}">')
lines.append(f" <name>{name}</name>")
uuid = config.get("uuid", "")
if uuid:
lines.append(f" <uuid>{uuid}</uuid>")
lines.append(f" <memory unit='KiB'>{memory_kb}</memory>")
lines.append(f" <currentMemory unit='KiB'>{memory_kb}</currentMemory>")
lines.append(f" <vcpu placement='static'>{vcpus}</vcpu>")
if uefi:
secure_boot = config.get("secure_boot", False)
if secure_boot:
lines.append(" <os firmware='efi'>")
lines.append(f" <type arch='{arch}' machine='{machine}'>hvm</type>")
lines.append(" <firmware>")
lines.append(" <feature enabled='yes' name='enrolled-keys'/>")
lines.append(" <feature enabled='yes' name='secure-boot'/>")
lines.append(" </firmware>")
lines.append(" <loader readonly='yes' secure='yes' type='pflash' format='raw'>/usr/share/OVMF/OVMF_CODE_4M.ms.fd</loader>")
lines.append(f" <nvram template='/usr/share/OVMF/OVMF_VARS_4M.ms.fd' templateFormat='raw' format='raw'>/var/lib/libvirt/qemu/nvram/{name}_VARS.fd</nvram>")
else:
lines.append(" <os firmware='efi'>")
lines.append(f" <type arch='{arch}' machine='{machine}'>hvm</type>")
lines.append(" <firmware>")
lines.append(" <feature enabled='no' name='enrolled-keys'/>")
lines.append(" <feature enabled='no' name='secure-boot'/>")
lines.append(" </firmware>")
lines.append(" <loader readonly='yes' secure='no' type='pflash' stateless='yes' format='raw'>/usr/share/ovmf/OVMF.amdsev.fd</loader>")
if boot_order:
for dev in boot_order:
lines.append(f" <boot dev='{dev}'/>")
else:
lines.append(" <boot dev='hd'/>")
lines.append(" <bootmenu enable='yes'/>")
else:
lines.append(" <os>")
lines.append(f" <type arch='{arch}' machine='{machine}'>hvm</type>")
if boot_order:
for dev in boot_order:
lines.append(f" <boot dev='{dev}'/>")
else:
lines.append(" <boot dev='hd'/>")
lines.append(" </os>")
lines.append(" <features><acpi/><apic/></features>")
lines.append(" <cpu mode='host-passthrough'/>")
lines.append(" <clock offset='utc'/>")
lines.append(" <devices>")
for ed in existing_disks:
lines.append(f" <disk type='{ed['type']}' device='{ed['device']}'>")
lines.append(f" <driver name='qemu' type='{ed['driver_type']}'/>")
if ed["type"] == "file" and ed["source_file"]:
lines.append(f" <source file='{ed['source_file']}'/>")
elif ed["type"] == "block" and ed["source_dev"]:
lines.append(f" <source dev='{ed['source_dev']}'/>")
elif ed["type"] == "volume":
pool = ed.get("source_pool", "default")
vol = ed.get("source_volume", "")
lines.append(f" <source pool='{pool}' volume='{vol}'/>")
elif ed["type"] == "network":
proto = ed.get("source_protocol", "iscsi")
sname = ed.get("source_name", "")
lines.append(f" <source protocol='{proto}' name='{sname}'/>")
target_dev = ed.get("target_dev", "vda")
target_bus = ed.get("target_bus", "virtio")
lines.append(f" <target dev='{target_dev}' bus='{target_bus}'/>")
if ed.get("readonly"):
lines.append(" <readonly/>")
lines.append(" </disk>")
for nd in new_disks:
dtype = nd.get("type", "file")
if dtype == "block":
lines.append(" <disk type='block' device='disk'>")
lines.append(f" <driver name='qemu' type='{nd.get('driver_type', 'raw')}'/>")
lines.append(f" <source dev='{nd.get('source_dev', '')}'/>")
lines.append(f" <target dev='{nd.get('target_dev', 'vdb')}' bus='{nd.get('target_bus', 'virtio')}'/>")
lines.append(" </disk>")
else:
lines.append(" <disk type='file' device='disk'>")
lines.append(f" <driver name='qemu' type='{nd.get('driver_type', 'qcow2')}'/>")
lines.append(f" <source file='{nd.get('source_file', '')}'/>")
lines.append(f" <target dev='{nd.get('target_dev', 'vdb')}' bus='{nd.get('target_bus', 'virtio')}'/>")
lines.append(" </disk>")
iso_idx = 0
for iso in iso_paths:
if isinstance(iso, dict):
iso_path = iso.get("path", "").strip()
iso_target = iso.get("target", "").strip()
else:
iso_path = str(iso).strip()
iso_target = ""
if iso_path:
dev = iso_target if iso_target else f"sd{chr(ord('c') + iso_idx)}"
lines.append(" <disk type='file' device='cdrom'>")
lines.append(" <driver name='qemu' type='raw'/>")
lines.append(f" <source file='{iso_path}'/>")
lines.append(f" <target dev='{dev}' bus='sata'/>")
lines.append(" <readonly/>")
lines.append(" </disk>")
iso_idx += 1
lines.append(f" <graphics type='vnc' port='{vnc_port}' autoport='yes' listen='{vnc_listen}'>")
lines.append(f" <listen type='address' address='{vnc_listen}'/>")
lines.append(" </graphics>")
if spice_enabled:
spice_attrs = f" <graphics type='spice' port='{spice_port}' autoport='yes' listen='{spice_listen}'"
if spice_tls_port:
spice_attrs += f" tlsPort='{spice_tls_port}'"
spice_attrs += ">"
lines.append(spice_attrs)
lines.append(f" <listen type='address' address='{spice_listen}'/>")
lines.append(" <image compression='off'/>")
lines.append(" <playback compression='on'/>")
lines.append(" <streaming mode='filter'/>")
lines.append(" <clipboard copypaste='yes'/>")
lines.append(" <filetransfer enable='yes'/>")
lines.append(" </graphics>")
lines.append(f" <interface type='{net_type}'>")
if net_type == "network":
lines.append(f" <source network='{net_source}'/>")
elif net_type == "bridge":
lines.append(f" <source bridge='{net_source}'/>")
elif net_type == "direct":
lines.append(f" <source dev='{net_source}'/>")
lines.append(f" <model type='{net_model}'/>")
lines.append(" </interface>")
for hd in hostdevs:
lines.append(" <hostdev mode='subsystem' type='pci' managed='yes'>")
lines.append(" <source>")
lines.append(f" <address domain='{hd.get('domain', '0x0000')}' bus='{hd.get('bus', '0x00')}' slot='{hd.get('slot', '0x00')}' function='{hd.get('function', '0x0')}'/>")
lines.append(" </source>")
lines.append(" </hostdev>")
for uhd in existing_usbs:
lines.append(" <hostdev mode='subsystem' type='usb' managed='yes'>")
lines.append(" <source>")
lines.append(f" <vendor id='{uhd['vendor_id']}'/>")
lines.append(f" <product id='{uhd['product_id']}'/>")
lines.append(" </source>")
lines.append(" </hostdev>")
usb_hostdevs = config.get("usb_hostdevs", [])
for uhd in usb_hostdevs:
lines.append(" <hostdev mode='subsystem' type='usb' managed='yes'>")
lines.append(" <source>")
lines.append(f" <vendor id='0x{uhd['vendor_id']}'/>")
lines.append(f" <product id='0x{uhd['product_id']}'/>")
lines.append(" </source>")
lines.append(" </hostdev>")
lines.append(" <video>")
if video_model == "qxl":
lines.append(" <model type='qxl' ram='65536' vram='65536' vgamem='16384' heads='1'/>")
elif video_model and video_model != "none":
lines.append(f" <model type='{video_model}' heads='1'/>")
elif not video_model:
lines.append(" <model type='virtio' heads='1'/>")
lines.append(" </video>")
if tpm_enabled:
lines.append(" <tpm model='tpm-crb'>")
lines.append(" <backend type='emulator'/>")
lines.append(" </tpm>")
sound_enabled = config.get("sound_enabled", False)
channel_spice = config.get("channel_spice", False)
usb_redirector_1 = config.get("usb_redirector_1", False)
usb_redirector_2 = config.get("usb_redirector_2", False)
if sound_enabled:
lines.append(" <sound model='ich9'/>")
if channel_spice:
lines.append(" <channel type='spicevmc'>")
lines.append(" <target type='virtio' name='com.redhat.spice.0'/>")
lines.append(" </channel>")
if usb_redirector_1:
lines.append(" <redirdev bus='usb' type='spicevmc'/>")
if usb_redirector_2:
lines.append(" <redirdev bus='usb' type='spicevmc'/>")
lines.append(" <memballoon model='virtio'/>")
lines.append(" </devices>")
lines.append(" <seclabel type='dynamic' model='apparmor' relabel='yes'/>")
lines.append("</domain>")
return "\n".join(lines)
@app.route("/api/vm/<name>/action", methods=["POST"])
def vm_action(name):
conn = get_conn()
try:
dom = conn.lookupByName(name)
except libvirt.libvirtError:
conn.close()
return jsonify({"error": f"VM '{name}' が見つかりません"}), 404
action = request.json.get("action")
try:
if action == "start":
dom.create()
elif action == "stop":
dom.shutdown()
elif action == "destroy":
dom.destroy()
elif action == "undefine":
if dom.isActive():
conn.close()
return jsonify({"error": "先にVMを停止してください"}), 400
delete_disk = request.json.get("delete_disk", False)
disk_paths = []
if delete_disk:
xml_str = dom.XMLDesc(0)
root = ET.fromstring(xml_str)
for disk in root.findall(".//disk"):
device = disk.get("device", "disk")
if device == "cdrom":
continue
source = disk.find("source")
if source is None:
continue
path = source.get("file", "") or source.get("dev", "")
if not path:
pool_name = source.get("pool", "")
vol_name = source.get("volume", "")
if pool_name and vol_name:
try:
pool = conn.storagePoolLookupByName(pool_name)
vol = pool.storageVolLookupByName(vol_name)
path = vol.path()
except Exception:
pass
if path:
disk_paths.append(path)
import subprocess
try:
subprocess.run(
["sudo", "virsh", "undefine", name, "--nvram"],
capture_output=True, timeout=10, check=True
)
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
try:
dom.undefine()
except libvirt.libvirtError as ue:
conn.close()
return jsonify({"error": str(ue)}), 400
if delete_disk and disk_paths:
for dp in disk_paths:
try:
subprocess.run(
["sudo", "rm", "-f", dp],
capture_output=True, timeout=10
)
except Exception:
pass
try:
nvram_path = f"/var/lib/libvirt/qemu/nvram/{name}_VARS.fd"
subprocess.run(
["sudo", "rm", "-f", nvram_path],
capture_output=True, timeout=5
)
except Exception:
pass
elif action == "suspend":
dom.suspend()
elif action == "resume":
dom.resume()
elif action == "reboot":
dom.reboot()
elif action == "autostart_on":
dom.setAutostart(1)
elif action == "autostart_off":
dom.setAutostart(0)
elif action == "usb_attach":
vendor_id = request.json.get("vendor_id", "")
product_id = request.json.get("product_id", "")
if not vendor_id or not product_id:
result = {"error": "vendor_id と product_id が必要です"}
else:
if dom.isActive():
usb_xml = f"""<hostdev mode='subsystem' type='usb' managed='yes'>
<source>
<vendor id='0x{vendor_id}'/>
<product id='0x{product_id}'/>
</source>
</hostdev>"""
try:
import subprocess, tempfile
with tempfile.NamedTemporaryFile(mode='w', suffix='.xml', delete=False) as f:
f.write(usb_xml)
tmp_path = f.name
r = subprocess.run(
["sudo", "virsh", "attach-device", name, "--file", tmp_path],
capture_output=True, text=True, timeout=10
)
subprocess.run(["sudo", "rm", "-f", tmp_path], capture_output=True, timeout=5)
if r.returncode != 0:
result = {"error": r.stderr.strip() or r.stdout.strip()}
except (subprocess.TimeoutExpired, Exception) as e:
result = {"error": str(e)}
else:
try:
xml_str = dom.XMLDesc(0)
root = ET.fromstring(xml_str)
devices_el = root.find(".//devices")
hostdev_el = ET.SubElement(devices_el, "hostdev")
hostdev_el.set("mode", "subsystem")
hostdev_el.set("type", "usb")
hostdev_el.set("managed", "yes")
source_el = ET.SubElement(hostdev_el, "source")
vendor_el = ET.SubElement(source_el, "vendor")
vendor_el.set("id", f"0x{vendor_id}")
product_el = ET.SubElement(source_el, "product")
product_el.set("id", f"0x{product_id}")
new_xml = ET.tostring(root, encoding="unicode")
conn.defineXML(new_xml)
except libvirt.libvirtError as e:
result = {"error": str(e)}
elif action == "usb_detach":
vendor_id = request.json.get("vendor_id", "")
product_id = request.json.get("product_id", "")
if not vendor_id or not product_id:
result = {"error": "vendor_id と product_id が必要です"}
else:
if dom.isActive():
usb_xml = f"""<hostdev mode='subsystem' type='usb' managed='yes'>
<source>
<vendor id='0x{vendor_id}'/>
<product id='0x{product_id}'/>
</source>
</hostdev>"""
try:
import subprocess, tempfile
with tempfile.NamedTemporaryFile(mode='w', suffix='.xml', delete=False) as f:
f.write(usb_xml)
tmp_path = f.name
r = subprocess.run(
["sudo", "virsh", "detach-device", name, "--file", tmp_path],
capture_output=True, text=True, timeout=10
)
subprocess.run(["sudo", "rm", "-f", tmp_path], capture_output=True, timeout=5)
if r.returncode != 0:
result = {"error": r.stderr.strip() or r.stdout.strip()}
except (subprocess.TimeoutExpired, Exception) as e:
result = {"error": str(e)}
else:
try:
xml_str = dom.XMLDesc(0)
root = ET.fromstring(xml_str)
devices_el = root.find(".//devices")
removed = False
for hd in root.findall(".//hostdev[@type='usb']"):
src = hd.find("source")
if src is not None:
v = src.find("vendor")
p = src.find("product")
if v is not None and p is not None:
vid = v.get("id", "").replace("0x", "")
pid = p.get("id", "").replace("0x", "")
if vid == vendor_id and pid == product_id:
devices_el.remove(hd)
removed = True
break
if not removed:
result = {"error": f"USBデバイス 0x{vendor_id}:0x{product_id} が見つかりません"}
else:
new_xml = ET.tostring(root, encoding="unicode")
conn.defineXML(new_xml)
except libvirt.libvirtError as e:
result = {"error": str(e)}
elif action == "disk_attach":
disk_xml = request.json.get("xml", "")
if not disk_xml:
result = {"error": "ディスクXMLが必要です"}
else:
try:
import subprocess, tempfile
with tempfile.NamedTemporaryFile(mode='w', suffix='.xml', delete=False) as f:
f.write(disk_xml)
tmp_path = f.name
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 == "disk_detach":
target_dev = request.json.get("target_dev", "")
if not target_dev:
result = {"error": "ターゲットデバイス名が必要です"}
else:
try:
xml_str = dom.XMLDesc(0)
root = ET.fromstring(xml_str)
devices_el = root.find(".//devices")
removed = False
for disk in root.findall(".//disk"):
target = disk.find("target")
if target is not None and target.get("dev") == target_dev:
devices_el.remove(disk)
removed = True
break
if not removed:
result = {"error": f"デバイス '{target_dev}' が見つかりません"}
else:
new_xml = ET.tostring(root, encoding="unicode")
conn.defineXML(new_xml)
result = {"success": True}
except libvirt.libvirtError as e:
result = {"error": str(e)}
elif action == "disk_update_source":
target_dev = request.json.get("target_dev", "")
new_source = request.json.get("new_source", "")
if not target_dev:
result = {"error": "ターゲットデバイス名が必要です"}
elif dom.isActive():
try:
import subprocess
if new_source:
r = subprocess.run(
["sudo", "virsh", "change-media", name, target_dev, "--source", new_source],
capture_output=True, text=True, timeout=10
)
else:
r = subprocess.run(
["sudo", "virsh", "change-media", name, target_dev, "--eject"],
capture_output=True, text=True, timeout=10
)
if r.returncode != 0:
result = {"error": r.stderr.strip() or r.stdout.strip()}
else:
result = {"success": True}
except (subprocess.TimeoutExpired, Exception) as e:
result = {"error": str(e)}
else:
try:
xml_str = dom.XMLDesc(0)
root = ET.fromstring(xml_str)
updated = False
for disk in root.findall(".//disk"):
target = disk.find("target")
if target is not None and target.get("dev") == target_dev:
source = disk.find("source")
if new_source:
if source is not None:
source.set("file", new_source)
else:
source = ET.SubElement(disk, "source")
source.set("file", new_source)
else:
if source is not None:
disk.remove(source)
updated = True
break
if not updated:
result = {"error": f"デバイス '{target_dev}' が見つかりません"}
else:
new_xml = ET.tostring(root, encoding="unicode")
conn.defineXML(new_xml)
result = {"success": True}
except libvirt.libvirtError as e:
result = {"error": str(e)}
elif action == "disk_resize":
target_dev = request.json.get("target_dev", "")
new_size = request.json.get("new_size", "")
if not target_dev or not new_size:
result = {"error": "ターゲットデバイス名と新しいサイズが必要です"}
elif dom.isActive():
result = {"error": "VMを停止してからディスクを拡大してください"}
else:
try:
xml_str = dom.XMLDesc(0)
root = ET.fromstring(xml_str)
disk_path = ""
for disk in root.findall(".//disk"):
target = disk.find("target")
if target is not None and target.get("dev") == target_dev:
source = disk.find("source")
if source is not None:
disk_path = source.get("file", "")
break
if not disk_path:
result = {"error": f"デバイス '{target_dev}' のパスが見つかりません"}
else:
import subprocess
r = subprocess.run(
["qemu-img", "info", "--output=json", disk_path],
capture_output=True, text=True, timeout=10
)
if r.returncode != 0:
result = {"error": f"ディスク情報の取得に失敗: {r.stderr.strip()}"}
else:
import json
info = json.loads(r.stdout)
old_size = info.get("virtual-size", 0)
r2 = subprocess.run(
["qemu-img", "resize", disk_path, new_size],
capture_output=True, text=True, timeout=30
)
if r2.returncode != 0:
result = {"error": f"リサイズ失敗: {r2.stderr.strip()}"}
else:
r3 = subprocess.run(
["qemu-img", "info", "--output=json", disk_path],
capture_output=True, text=True, timeout=10
)
new_info = json.loads(r3.stdout) if r3.returncode == 0 else {}
result = {
"success": True,
"old_size": old_size,
"new_size": new_info.get("virtual-size", 0)
}
except Exception as e:
result = {"error": str(e)}
else:
conn.close()
return jsonify({"error": f"不明なアクション: {action}"}), 400
result = {"success": True}
except libvirt.libvirtError as e:
result = {"error": str(e)}
conn.close()
return jsonify(result)
@app.route("/vm/create", methods=["GET", "POST"])
def vm_create():
conn = get_conn()
storage_pools = []
for pname in conn.listStoragePools():
pool = conn.storagePoolLookupByName(pname)
pool.refresh(0)
storage_pools.append({
"name": pname,
"active": pool.isActive(),
"type": pool.info()[0],
})
networks = []
for nname in conn.listNetworks():
net = conn.networkLookupByName(nname)
networks.append({"name": nname, "active": net.isActive()})
hostdevs = []
try:
for nd in conn.listAllNodeDevices(0):
try:
nd_xml = nd.XMLDesc(0)
nd_root = ET.fromstring(nd_xml)
driver_el = nd_root.find("driver")
if driver_el is not None and driver_el.get("name") == "vfio-pci":
cap = nd_root.find("capability")
vendor_el = cap.find("vendor") if cap is not None else None
product_el = cap.find("product") if cap is not None else None
hostdevs.append({
"name": nd.name(),
"vendor_id": vendor_el.get("id", "") if vendor_el is not None else "",
"product_id": product_el.get("id", "") if product_el is not None else "",
"description": cap.get("id", "") if cap is not None else nd.name(),
})
except Exception:
continue
except Exception:
pass
conn.close()
if request.method == "POST":
config = request.json
try:
conn = get_conn()
disk_size_gb = config.get("disk_size_gb", "")
disk_pool = config.get("disk_pool", "default")
vm_name = config.get("name", "").strip()
disk_path = ""
if disk_size_gb and vm_name:
disk_path = _create_volume(conn, vm_name, disk_pool, int(disk_size_gb))
config["_disk_path"] = disk_path
xml, errors = _build_vm_xml(config)
if errors:
conn.close()
return jsonify({"error": errors}), 400
conn.defineXML(xml)
autostart = config.get("autostart", False)
if autostart:
dom = conn.lookupByName(vm_name)
dom.setAutostart(1)
conn.close()
return jsonify({"success": True})
except libvirt.libvirtError as e:
return jsonify({"error": str(e)}), 400
usb_devices = _get_usb_devices()
return render_template(
"vm_create.html",
storage_pools=storage_pools,
networks=networks,
hostdevs=hostdevs,
usb_devices=usb_devices,
)
def _create_volume(conn, vm_name, pool_name, size_gb):
try:
pool = conn.storagePoolLookupByName(pool_name)
except libvirt.libvirtError:
return
vol_name = f"{vm_name}.qcow2"
vol_xml = f"""
<volume>
<name>{vol_name}</name>
<capacity unit='G'>{size_gb}</capacity>
<target>
<format type='qcow2'/>
</target>
</volume>"""
try:
vol = pool.createXML(vol_xml, 0)
except libvirt.libvirtError:
try:
vol = pool.storageVolLookupByName(vol_name)
except libvirt.libvirtError:
return ""
vol_path = ""
try:
vol_path = vol.path()
import subprocess
subprocess.run(
["sudo", "chown", "libvirt-qemu:kvm", vol_path],
capture_output=True, timeout=5, check=True
)
subprocess.run(
["sudo", "chmod", "0644", vol_path],
capture_output=True, timeout=5, check=True
)
except Exception:
pass
return vol_path
def _build_vm_xml(config):
name = config.get("name", "").strip()
if not name:
return None, "VM名を入力してください"
domain_type = config.get("domain_type", "kvm")
vcpus = int(config.get("vcpus", 2))
memory_mb = int(config.get("memory_mb", 4096))
memory_kb = memory_mb * 1024
arch = config.get("arch", "x86_64")
machine = config.get("machine", "pc-q35-10.2")
disk_size_gb = config.get("disk_size_gb", "")
disk_pool = config.get("disk_pool", "default")
disk_bus = config.get("disk_bus", "virtio")
net_type = config.get("net_type", "network")
net_source = config.get("net_source", "default")
net_model = config.get("net_model", "virtio")
vnc_port = config.get("vnc_port", "") or "-1"
try:
int(vnc_port)
except (ValueError, TypeError):
vnc_port = "-1"
vnc_listen = config.get("vnc_listen", "") or "0.0.0.0"
vnc_passwd = config.get("vnc_passwd", "")
spice_enabled = config.get("spice_enabled", False)
spice_port = config.get("spice_port", "") or "-1"
try:
int(spice_port)
except (ValueError, TypeError):
spice_port = "-1"
spice_tls_port = config.get("spice_tls_port", "") or ""
spice_listen = config.get("spice_listen", "") or "0.0.0.0"
video_model = config.get("video_model", "")
if not video_model:
video_model = "qxl" if spice_enabled else "virtio"
tpm_enabled = config.get("tpm_enabled", False)
sound_enabled = config.get("sound_enabled", False)
channel_spice = config.get("channel_spice", False)
usb_redirector_1 = config.get("usb_redirector_1", False)
usb_redirector_2 = config.get("usb_redirector_2", False)
boot_order = config.get("boot_order", [])
disks_config = config.get("disks", [])
hostdevs = config.get("hostdevs", [])
existing_usbs = config.get("existing_usbs", [])
lines = []
lines.append(f'<domain type="{domain_type}">')
lines.append(f" <name>{name}</name>")
lines.append(f" <memory unit='KiB'>{memory_kb}</memory>")
lines.append(f" <currentMemory unit='KiB'>{memory_kb}</currentMemory>")
lines.append(f" <vcpu placement='static'>{vcpus}</vcpu>")
uefi = config.get("uefi", False)
secure_boot = config.get("secure_boot", False)
boot_order = config.get("boot_order", [])
if uefi:
if secure_boot:
lines.append(" <os firmware='efi'>")
lines.append(f" <type arch='{arch}' machine='{machine}'>hvm</type>")
lines.append(" <firmware>")
lines.append(" <feature enabled='yes' name='enrolled-keys'/>")
lines.append(" <feature enabled='yes' name='secure-boot'/>")
lines.append(" </firmware>")
lines.append(" <loader readonly='yes' secure='yes' type='pflash' format='raw'>/usr/share/OVMF/OVMF_CODE_4M.ms.fd</loader>")
lines.append(f" <nvram template='/usr/share/OVMF/OVMF_VARS_4M.ms.fd' templateFormat='raw' format='raw'>/var/lib/libvirt/qemu/nvram/{name}_VARS.fd</nvram>")
else:
lines.append(" <os firmware='efi'>")
lines.append(f" <type arch='{arch}' machine='{machine}'>hvm</type>")
lines.append(" <firmware>")
lines.append(" <feature enabled='no' name='enrolled-keys'/>")
lines.append(" <feature enabled='no' name='secure-boot'/>")
lines.append(" </firmware>")
lines.append(" <loader readonly='yes' secure='no' type='pflash' stateless='yes' format='raw'>/usr/share/ovmf/OVMF.amdsev.fd</loader>")
if boot_order:
for dev in boot_order:
lines.append(f" <boot dev='{dev}'/>")
else:
lines.append(" <boot dev='hd'/>")
lines.append(" <bootmenu enable='yes'/>")
else:
lines.append(" <os>")
lines.append(f" <type arch='{arch}' machine='{machine}'>hvm</type>")
if boot_order:
for dev in boot_order:
lines.append(f" <boot dev='{dev}'/>")
else:
lines.append(" <boot dev='hd'/>")
lines.append(" </os>")
lines.append(" <features>")
lines.append(" <acpi/>")
lines.append(" <apic/>")
lines.append(" </features>")
lines.append(" <cpu mode='host-passthrough'/>")
lines.append(" <clock offset='utc'/>")
lines.append(" <devices>")
if disk_size_gb:
disk_path = config.get("_disk_path", "")
if disk_path:
lines.append(" <disk type='file' device='disk'>")
lines.append(" <driver name='qemu' type='qcow2'/>")
lines.append(f" <source file='{disk_path}'/>")
lines.append(f" <target dev='vda' bus='{disk_bus}'/>")
lines.append(" </disk>")
else:
lines.append(" <disk type='volume' device='disk'>")
lines.append(" <driver name='qemu' type='qcow2'/>")
lines.append(f" <source pool='{disk_pool}' volume='{name}.qcow2'/>")
lines.append(f" <target dev='vda' bus='{disk_bus}'/>")
lines.append(" </disk>")
dev_letters = "bcdefghijklmnop"
dev_idx = 0
iso_paths = config.get("iso_paths", [])
iso_idx = 0
for iso in iso_paths:
if isinstance(iso, dict):
iso_path = iso.get("path", "").strip()
iso_target = iso.get("target", "").strip()
else:
iso_path = str(iso).strip()
iso_target = ""
if iso_path:
dev = iso_target if iso_target else f"sd{chr(ord('c') + iso_idx)}"
lines.append(" <disk type='file' device='cdrom'>")
lines.append(" <driver name='qemu' type='raw'/>")
lines.append(f" <source file='{iso_path}'/>")
lines.append(f" <target dev='{dev}' bus='sata'/>")
lines.append(" <readonly/>")
lines.append(" </disk>")
iso_idx += 1
for dc in disks_config:
dtype = dc.get("type", "")
if dtype == "block":
lines.append(" <disk type='block' device='disk'>")
driver_type = dc.get("driver_type", "raw")
lines.append(f" <driver name='qemu' type='{driver_type}'/>")
lines.append(f" <source dev='{dc.get('source_dev', '')}'/>")
target_dev = dc.get("target_dev", f"vd{dev_letters[dev_idx]}")
target_bus = dc.get("target_bus", "virtio")
lines.append(f" <target dev='{target_dev}' bus='{target_bus}'/>")
lines.append(" </disk>")
elif dtype == "file":
lines.append(" <disk type='file' device='disk'>")
driver_type = dc.get("driver_type", "qcow2")
lines.append(f" <driver name='qemu' type='{driver_type}'/>")
lines.append(f" <source file='{dc.get('source_file', '')}'/>")
target_dev = dc.get("target_dev", f"vd{dev_letters[dev_idx]}")
target_bus = dc.get("target_bus", "virtio")
lines.append(f" <target dev='{target_dev}' bus='{target_bus}'/>")
lines.append(" </disk>")
dev_idx += 1
lines.append(f" <graphics type='vnc' port='{vnc_port}' autoport='yes' listen='{vnc_listen}'>")
lines.append(f" <listen type='address' address='{vnc_listen}'/>")
lines.append(" </graphics>")
if spice_enabled:
spice_attrs = f" <graphics type='spice' port='{spice_port}' autoport='yes' listen='{spice_listen}'"
if spice_tls_port:
spice_attrs += f" tlsPort='{spice_tls_port}'"
spice_attrs += ">"
lines.append(spice_attrs)
lines.append(f" <listen type='address' address='{spice_listen}'/>")
lines.append(" <image compression='off'/>")
lines.append(" <playback compression='on'/>")
lines.append(" <streaming mode='filter'/>")
lines.append(" <clipboard copypaste='yes'/>")
lines.append(" <filetransfer enable='yes'/>")
lines.append(" </graphics>")
lines.append(f" <interface type='{net_type}'>")
if net_type == "network":
lines.append(f" <source network='{net_source}'/>")
elif net_type == "bridge":
lines.append(f" <source bridge='{net_source}'/>")
elif net_type == "direct":
lines.append(f" <source dev='{net_source}'/>")
lines.append(f" <model type='{net_model}'/>")
lines.append(" </interface>")
for hd in hostdevs:
hd_domain = hd.get("domain", "0x0000")
hd_bus = hd.get("bus", "0x00")
hd_slot = hd.get("slot", "0x00")
hd_function = hd.get("function", "0x0")
lines.append(" <hostdev mode='subsystem' type='pci' managed='yes'>")
lines.append(" <source>")
lines.append(f" <address domain='{hd_domain}' bus='{hd_bus}' slot='{hd_slot}' function='{hd_function}'/>")
lines.append(" </source>")
lines.append(" </hostdev>")
for uhd in existing_usbs:
lines.append(" <hostdev mode='subsystem' type='usb' managed='yes'>")
lines.append(" <source>")
lines.append(f" <vendor id='{uhd['vendor_id']}'/>")
lines.append(f" <product id='{uhd['product_id']}'/>")
lines.append(" </source>")
lines.append(" </hostdev>")
usb_hostdevs = config.get("usb_hostdevs", [])
for uhd in usb_hostdevs:
lines.append(" <hostdev mode='subsystem' type='usb' managed='yes'>")
lines.append(" <source>")
lines.append(f" <vendor id='0x{uhd['vendor_id']}'/>")
lines.append(f" <product id='0x{uhd['product_id']}'/>")
lines.append(" </source>")
lines.append(" </hostdev>")
lines.append(" <video>")
if video_model == "qxl":
lines.append(" <model type='qxl' ram='65536' vram='65536' vgamem='16384' heads='1'/>")
else:
lines.append(" <model type='virtio' heads='1'/>")
lines.append(" </video>")
if tpm_enabled:
lines.append(" <tpm model='tpm-crb'>")
lines.append(" <backend type='emulator'/>")
lines.append(" </tpm>")
if sound_enabled:
lines.append(" <sound model='ich9'/>")
if channel_spice:
lines.append(" <channel type='spicevmc'>")
lines.append(" <target type='virtio' name='com.redhat.spice.0'/>")
lines.append(" </channel>")
if usb_redirector_1:
lines.append(" <redirdev bus='usb' type='spicevmc'/>")
if usb_redirector_2:
lines.append(" <redirdev bus='usb' type='spicevmc'/>")
lines.append(" <memballoon model='virtio'/>")
lines.append(" </devices>")
lines.append(" <seclabel type='dynamic' model='apparmor' relabel='yes'/>")
lines.append("</domain>")
return "\n".join(lines), None
@app.route("/api/vm/<name>/xml", methods=["GET", "PUT"])
def vm_xml(name):
conn = get_conn()
try:
dom = conn.lookupByName(name)
except libvirt.libvirtError:
conn.close()
return jsonify({"error": f"VM '{name}' が見つかりません"}), 404
if request.method == "GET":
xml_str = dom.XMLDesc(0)
conn.close()
return jsonify({"xml": xml_str})
else:
new_xml = request.json.get("xml", "")
try:
conn.defineXML(new_xml)
conn.close()
return jsonify({"success": True})
except libvirt.libvirtError as e:
conn.close()
return jsonify({"error": str(e)}), 400
@app.route("/api/vm/<name>/bootorder", methods=["GET", "PUT"])
def vm_bootorder(name):
conn = get_conn()
try:
dom = conn.lookupByName(name)
except libvirt.libvirtError:
conn.close()
return jsonify({"error": f"VM '{name}' が見つかりません"}), 404
if dom.isActive():
conn.close()
return jsonify({"error": "VMを停止してからブート順序を変更してください"}), 400
if request.method == "GET":
xml_str = dom.XMLDesc(0)
root = ET.fromstring(xml_str)
boot_order = []
idx = 1
for os_boot in root.findall(".//os/boot"):
dev = os_boot.get("dev", "")
if dev:
boot_order.append({"dev": dev, "order": idx})
idx += 1
conn.close()
return jsonify({"boot_order": boot_order})
else:
boot_devs = request.json.get("boot_order", [])
xml_str = dom.XMLDesc(0)
root = ET.fromstring(xml_str)
for os_boot in root.findall(".//os/boot"):
root.find(".//os").remove(os_boot)
os_el = root.find(".//os")
for bd in boot_devs:
dev = bd.get("dev", "")
if dev:
boot_el = ET.SubElement(os_el, "boot")
boot_el.set("dev", dev)
new_xml = ET.tostring(root, encoding="unicode")
try:
conn.defineXML(new_xml)
conn.close()
return jsonify({"success": True})
except libvirt.libvirtError as e:
conn.close()
return jsonify({"error": str(e)}), 400
@app.route("/api/storage")
def api_storage():
conn = get_conn()
pools = []
for pname in conn.listStoragePools():
pool = conn.storagePoolLookupByName(pname)
pool.refresh(0)
info = pool.info()
volumes = []
for vol_name in pool.listVolumes():
vol = pool.storageVolLookupByName(vol_name)
vol_info = vol.info()
volumes.append({
"name": vol_name,
"capacity_mb": vol_info[1] // (1024 * 1024),
"allocation_mb": vol_info[2] // (1024 * 1024),
})
pools.append({
"name": pname,
"active": pool.isActive(),
"type": info[0],
"capacity_mb": info[1] // (1024 * 1024),
"allocation_mb": info[2] // (1024 * 1024),
"volumes": volumes,
})
conn.close()
return jsonify(pools)
@app.route("/api/block-devices")
def api_block_devices():
import subprocess
devices = []
try:
result = subprocess.run(
["lsblk", "-J", "-o", "NAME,SIZE,TYPE,MOUNTPOINT,MODEL"],
capture_output=True, text=True, timeout=5
)
if result.returncode == 0:
import json
data = json.loads(result.stdout)
for dev in data.get("blockdevices", []):
name = dev.get("name", "")
dev_type = dev.get("type", "")
size = dev.get("size", "")
model = (dev.get("model") or "").strip()
mountpoint = dev.get("mountpoint") or ""
if dev_type in ("disk", "part", "lvm"):
label = f"/dev/{name}"
if model:
label += f" ({model})"
label += f" - {size}"
if mountpoint:
label += f" [{mountpoint}]"
devices.append({
"path": f"/dev/{name}",
"name": name,
"size": size,
"type": dev_type,
"model": model,
"mountpoint": mountpoint,
"label": label,
})
for child in dev.get("children", []):
cname = child.get("name", "")
ctype = child.get("type", "")
csize = child.get("size", "")
cmodel = (child.get("model") or "").strip()
cmountpoint = child.get("mountpoint") or ""
if ctype in ("part", "lvm"):
clabel = f"/dev/{cname}"
if cmodel:
clabel += f" ({cmodel})"
clabel += f" - {csize}"
if cmountpoint:
clabel += f" [{cmountpoint}]"
devices.append({
"path": f"/dev/{cname}",
"name": cname,
"size": csize,
"type": ctype,
"model": cmodel,
"mountpoint": cmountpoint,
"label": clabel,
})
except Exception:
pass
return jsonify(devices)
@app.route("/api/storage-pool-volumes")
def api_storage_pool_volumes():
conn = get_conn()
volumes = []
for pname in conn.listStoragePools():
try:
pool = conn.storagePoolLookupByName(pname)
pool.refresh(0)
for vol_name in pool.listVolumes():
vol = pool.storageVolLookupByName(vol_name)
vol_info = vol.info()
vol_path = vol.path()
size_mb = vol_info[1] // (1024 * 1024)
volumes.append({
"path": vol_path,
"name": vol_name,
"pool": pname,
"size_mb": size_mb,
"label": f"[{pname}] {vol_name} ({size_mb} MB)",
})
except Exception:
continue
conn.close()
return jsonify(volumes)
@app.route("/api/iso-files")
def api_iso_files():
conn = get_conn()
isos = []
iso_exts = ('.iso', '.img', '.raw', '.qcow2', '.vmdk', '.vhdx', '.vdi')
for pname in conn.listStoragePools():
try:
pool = conn.storagePoolLookupByName(pname)
pool.refresh(0)
for vol_name in pool.listVolumes():
if any(vol_name.lower().endswith(ext) for ext in iso_exts):
vol = pool.storageVolLookupByName(vol_name)
vol_info = vol.info()
isos.append({
"name": vol_name,
"path": vol.path(),
"pool": pname,
"size_mb": vol_info[1] // (1024 * 1024),
"label": f"[{pname}] {vol_name} ({vol_info[1] // (1024 * 1024)} MB)",
})
except Exception:
continue
conn.close()
return jsonify(isos)
@app.route("/api/networks")
def api_networks():
conn = get_conn()
networks = []
for nname in conn.listNetworks():
net = conn.networkLookupByName(nname)
networks.append({
"name": nname,
"active": net.isActive(),
"autostart": net.autostart(),
"bridge": net.bridgeName() if net.bridgeName() else "",
})
conn.close()
return jsonify(networks)
_websockify_procs = {}
import subprocess as _sp
try:
_sp.run(["pkill", "-9", "-f", "websockify"], capture_output=True, timeout=5)
except Exception:
pass
@app.route("/api/vm/<name>/snapshots")
def api_snapshots(name):
conn = get_conn()
try:
dom = conn.lookupByName(name)
except libvirt.libvirtError:
conn.close()
return jsonify({"error": f"VM '{name}' が見つかりません"}), 404
snapshots = []
try:
for snap in dom.listAllSnapshots(0):
xml_str = snap.getXMLDesc(0)
root = ET.fromstring(xml_str)
creation = root.findtext("creationTime", "0")
state = root.findtext("state", "unknown")
sname = root.findtext("name", "")
desc = root.findtext("description", "")
snapshots.append({
"name": sname,
"state": state,
"creation": int(creation) if creation else 0,
"description": desc,
})
except libvirt.libvirtError:
pass
conn.close()
snapshots.sort(key=lambda s: s["creation"], reverse=True)
return jsonify(snapshots)
@app.route("/api/vm/<name>/snapshot-create", methods=["POST"])
def api_snapshot_create(name):
conn = get_conn()
try:
dom = conn.lookupByName(name)
except libvirt.libvirtError:
conn.close()
return jsonify({"error": f"VM '{name}' が見つかりません"}), 404
config = request.json or {}
snap_name = config.get("name", "").strip()
snap_desc = config.get("description", "").strip()
if not snap_name:
import datetime
snap_name = datetime.datetime.now().strftime("snap-%Y%m%d-%H%M%S")
snap_xml = f"""<domainsnapshot>
<name>{snap_name}</name>
<description>{snap_desc}</description>
</domainsnapshot>"""
try:
flags = libvirt.VIR_DOMAIN_SNAPSHOT_CREATE_ATOMIC
dom.snapshotCreateXML(snap_xml, flags)
conn.close()
return jsonify({"success": True})
except libvirt.libvirtError as e:
conn.close()
return jsonify({"error": str(e)}), 400
@app.route("/api/vm/<name>/snapshot-delete", methods=["POST"])
def api_snapshot_delete(name):
conn = get_conn()
try:
dom = conn.lookupByName(name)
except libvirt.libvirtError:
conn.close()
return jsonify({"error": f"VM '{name}' が見つかりません"}), 404
snap_name = (request.json or {}).get("name", "")
if not snap_name:
conn.close()
return jsonify({"error": "スナップショット名が必要です"}), 400
try:
snap = dom.snapshotLookupByName(snap_name, 0)
snap.delete(0)
conn.close()
return jsonify({"success": True})
except libvirt.libvirtError as e:
conn.close()
return jsonify({"error": str(e)}), 400
@app.route("/api/vm/<name>/snapshot-revert", methods=["POST"])
def api_snapshot_revert(name):
conn = get_conn()
try:
dom = conn.lookupByName(name)
except libvirt.libvirtError:
conn.close()
return jsonify({"error": f"VM '{name}' が見つかりません"}), 404
if dom.isActive():
conn.close()
return jsonify({"error": "VMを停止してから元に戻してください"}), 400
snap_name = (request.json or {}).get("name", "")
if not snap_name:
conn.close()
return jsonify({"error": "スナップショット名が必要です"}), 400
try:
snap = dom.snapshotLookupByName(snap_name, 0)
flags = libvirt.VIR_DOMAIN_SNAPSHOT_REVERT_RUNNING
dom.revertToSnapshot(snap, flags)
conn.close()
return jsonify({"success": True})
except libvirt.libvirtError as e:
conn.close()
return jsonify({"error": str(e)}), 400
@app.route("/api/vm/<name>/vnc-info")
def api_vnc_info(name):
conn = get_conn()
try:
dom = conn.lookupByName(name)
except libvirt.libvirtError:
conn.close()
return jsonify({"error": f"VM '{name}' が見つかりません"}), 404
xml_str = dom.XMLDesc(0)
root = ET.fromstring(xml_str)
vnc_port = None
vnc_listen = "127.0.0.1"
for graphics in root.findall(".//graphics"):
if graphics.get("type") == "vnc":
vnc_port = graphics.get("port", "")
vnc_listen = graphics.get("listen", "127.0.0.1")
listen_el = graphics.find("listen")
if listen_el is not None:
vnc_listen = listen_el.get("address", vnc_listen)
break
is_active = dom.isActive()
conn.close()
if not vnc_port:
return jsonify({"error": "VNCが有効ではありません"}), 400
return jsonify({"port": vnc_port, "listen": vnc_listen, "active": is_active})
@app.route("/api/vm/<name>/status")
def api_vm_status(name):
conn = get_conn()
try:
dom = conn.lookupByName(name)
except libvirt.libvirtError:
conn.close()
return jsonify({"error": f"VM '{name}' が見つかりません"}), 404
is_active = dom.isActive()
conn.close()
return jsonify({"active": is_active})
@app.route("/api/vm/<name>/console-proxy", methods=["POST", "DELETE"])
def console_proxy(name):
import subprocess, signal
if request.method == "DELETE":
proc = _websockify_procs.pop(name, None)
if proc:
if proc.poll() is None:
proc.kill()
try:
proc.wait(timeout=3)
except Exception:
pass
try:
subprocess.run(
["pkill", "-9", "-f", "websockify"],
capture_output=True, timeout=5
)
except Exception:
pass
return jsonify({"success": True})
conn = get_conn()
try:
dom = conn.lookupByName(name)
except libvirt.libvirtError:
conn.close()
return jsonify({"error": f"VM '{name}' が見つかりません"}), 404
xml_str = dom.XMLDesc(0)
root = ET.fromstring(xml_str)
vnc_port = None
for graphics in root.findall(".//graphics"):
if graphics.get("type") == "vnc":
vnc_port = graphics.get("port", "")
break
conn.close()
if not vnc_port:
return jsonify({"error": "VNCが有効ではありません"}), 400
proc = _websockify_procs.get(name)
if proc and proc.poll() is None:
ws_port = proc.ws_port if hasattr(proc, 'ws_port') else None
if ws_port:
try:
import urllib.request
urllib.request.urlopen(f"http://127.0.0.1:{ws_port}/", timeout=2)
return jsonify({"ws_port": ws_port})
except Exception:
proc.kill()
proc.wait(timeout=3)
_websockify_procs.pop(name, None)
try:
import subprocess as sp
sp.run(["pkill", "-9", "-f", "websockify"], capture_output=True, timeout=5)
import time as _time
_time.sleep(0.3)
import socket
s = socket.socket()
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("", 0))
ws_port = s.getsockname()[1]
s.close()
proc = subprocess.Popen(
["websockify", "--web", "/usr/share/novnc/", str(ws_port), f"127.0.0.1:{vnc_port}"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
)
proc.ws_port = ws_port
_websockify_procs[name] = proc
return jsonify({"ws_port": ws_port})
except FileNotFoundError:
return jsonify({"error": "websockifyがインストールされていません。 sudo apt install novnc python3-websockify"}), 500
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route("/vm/<name>/console")
def vm_console(name):
return render_template("vm_console.html", vm_name=name)
@app.route("/novnc/<path:filename>")
def novnc_static(filename):
from flask import send_from_directory
return send_from_directory("/usr/share/novnc", filename)
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8090, debug=True)
ENDOFFILE_APP_PY
# Write templates/base.html
cat << 'ENDOFFILE_BASE_HTML' > /opt/vm-manage/templates/base.html
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}VM Manager{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css">
</head>
<body>
<nav class="sidebar">
<div class="sidebar-header">
<i class="fas fa-server"></i>
<span>VM Manager</span>
</div>
<ul class="sidebar-menu">
<li><a href="/vm/create" class="{% if request.endpoint == 'vm_create' %}active{% endif %}"><i class="fas fa-plus-circle"></i> 新規作成</a></li>
</ul>
<div style="padding:8px 16px;border-top:1px solid var(--border)">
<div style="font-size:0.75em;color:var(--text-secondary);margin-bottom:6px">仮想マシン</div>
{% for vm in sidebar_vms %}
<a href="/vm/{{ vm.name }}" style="display:flex;align-items:center;gap:8px;padding:6px 0;color:var(--text-primary);text-decoration:none;font-size:0.9em;{% if request.path == '/vm/' + vm.name %}color:var(--accent){% endif %}">
<span style="width:8px;height:8px;border-radius:50%;flex-shrink:0;background:{% if vm.state == 'running' %}#2ecc71{% else %}#666{% endif %}"></span>
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">{{ vm.name }}</span>
</a>
{% endfor %}
{% if not sidebar_vms %}
<div style="font-size:0.85em;color:var(--text-secondary)">VMなし</div>
{% endif %}
</div>
</nav>
<main class="content">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }}">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
{% block content %}{% endblock %}
</main>
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
{% block scripts %}{% endblock %}
</body>
</html>
ENDOFFILE_BASE_HTML
# Write templates/index.html
cat << 'ENDOFFILE_INDEX_HTML' > /opt/vm-manage/templates/index.html
{% extends "base.html" %}
{% block title %}VM一覧 - VM Manager{% endblock %}
{% block content %}
<h1><i class="fas fa-list"></i> 仮想マシン一覧</h1>
<div class="card-header">
<span>全{{ vms|length }}台</span>
<a href="/vm/create" class="btn btn-primary"><i class="fas fa-plus"></i> 新規作成</a>
</div>
{% if vms %}
<div class="grid grid-2">
{% for vm in vms %}
<div class="vm-card">
<div class="vm-card-header">
<h3><a href="/vm/{{ vm.name }}" style="color:inherit;text-decoration:none">{{ vm.name }}</a></h3>
<span class="status-badge status-{{ vm.state }}">{{ vm.state }}</span>
</div>
<dl class="vm-info">
<dt>仮想化</dt><dd>{{ vm.domain_type|upper }}</dd>
<dt>マシン</dt><dd>{{ vm.machine }}</dd>
<dt>CPU</dt><dd>{{ vm.vcpus }} vCPU</dd>
<dt>メモリ</dt><dd>{{ vm.memory_mb }} MB</dd>
</dl>
<div class="btn-group">
{% if vm.state == 'stopped' %}
<button class="btn btn-success btn-sm" onclick="vmAction('{{ vm.name }}', 'start')"><i class="fas fa-play"></i> 起動</button>
{% else %}
<button class="btn btn-warning btn-sm" onclick="vmAction('{{ vm.name }}', 'stop')"><i class="fas fa-stop"></i> 停止</button>
<button class="btn btn-danger btn-sm" onclick="vmAction('{{ vm.name }}', 'destroy')"><i class="fas fa-power-off"></i> 強制停止</button>
<button class="btn btn-info btn-sm" onclick="vmAction('{{ vm.name }}', 'reboot')"><i class="fas fa-redo"></i> 再起動</button>
{% endif %}
<a href="/vm/{{ vm.name }}" class="btn btn-info btn-sm"><i class="fas fa-edit"></i> 編集</a>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="card" style="text-align:center;padding:60px">
<i class="fas fa-desktop" style="font-size:3em;color:var(--text-secondary);margin-bottom:15px"></i>
<p style="color:var(--text-secondary)">仮想マシンがありません</p>
<a href="/vm/create" class="btn btn-primary" style="margin-top:15px"><i class="fas fa-plus"></i> 最初のVMを作成</a>
</div>
{% endif %}
{% endblock %}
{% block scripts %}
<script>
function vmAction(name, action) {
if (action === 'undefine') {
if (!confirm('このVMを削除しますか?')) return;
const deleteDisk = confirm('ディスクファイルも削除しますか?');
fetch(`/api/vm/${name}/action`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({action: action, delete_disk: deleteDisk})
})
.then(r => r.json())
.then(data => {
if (data.error) alert('エラー: ' + data.error);
else location.reload();
})
.catch(err => alert('通信エラー: ' + err));
return;
}
const msgs = {destroy: '強制停止しますか?', stop: 'シャットダウンしますか?'};
if (msgs[action] && !confirm(msgs[action])) return;
fetch(`/api/vm/${name}/action`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({action: action})
})
.then(r => r.json())
.then(data => {
if (data.error) alert('エラー: ' + data.error);
else location.reload();
})
.catch(err => alert('通信エラー: ' + err));
}
</script>
{% endblock %}
ENDOFFILE_INDEX_HTML
# Write templates/vm_create.html
cat << 'ENDOFFILE_VM_CREATE_HTML' > /opt/vm-manage/templates/vm_create.html
{% extends "base.html" %}
{% block title %}新規作成 - VM Manager{% endblock %}
{% block content %}
<h1><i class="fas fa-plus-circle"></i> 仮想マシン新規作成</h1>
<p style="color:var(--text-secondary);margin-bottom:20px">VNC/SPICE同時有効化、ブロックデバイスパススルー、PCI passthroughなどが可能です</p>
<form id="vm-form">
<div class="card">
<h2><i class="fas fa-cog"></i> 基本設定</h2>
<div class="form-group">
<label>VM名 *</label>
<input type="text" name="name" required placeholder="例: win11, ubuntu2604">
</div>
<div class="form-row">
<div class="form-group">
<label>仮想化タイプ</label>
<select name="domain_type">
<option value="kvm">KVM(推奨)</option>
<option value="qemu">QEMU(エミュレーション)</option>
</select>
</div>
<div class="form-group">
<label>アーキテクチャ</label>
<select name="arch">
<option value="x86_64">x86_64</option>
<option value="aarch64">aarch64</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>マシンタイプ</label>
<select name="machine">
<option value="pc-q35-10.2" selected>pc-q35-10.2 (Q35)</option>
<option value="pc-q35-9.1">pc-q35-9.1</option>
<option value="pc-q35-9.0">pc-q35-9.0</option>
<option value="pc-i440fx-9.1">pc-i440fx-9.1</option>
<option value="virt">virt (ARM)</option>
</select>
</div>
<div class="form-group">
<label>vCPU数</label>
<select name="vcpus">
{% for n in [1,2,4,6,8,12,16,24,32,48,64] %}
<option value="{{ n }}" {% if n == 2 %}selected{% endif %}>{{ n }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="form-group">
<label>メモリ (MB)</label>
<select name="memory_mb">
{% for mb, label in [(512,'512 MB'),(1024,'1 GB'),(2048,'2 GB'),(3072,'3 GB'),(4096,'4 GB'),(6144,'6 GB'),(8192,'8 GB'),(12288,'12 GB'),(16384,'16 GB'),(24576,'24 GB'),(32768,'32 GB'),(49152,'48 GB'),(65536,'64 GB')] %}
<option value="{{ mb }}" {% if mb == 4096 %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="checkbox-group">
<input type="checkbox" name="uefi" id="uefi" checked onchange="document.getElementById('secureboot-group').style.display=this.checked?'flex':'none'">
<label for="uefi">UEFIブート (OVMF) を有効にする</label>
<small style="margin-left:5px;color:var(--text-secondary)">Windows 11等で必須</small>
</div>
<div class="checkbox-group" id="secureboot-group" style="display:flex">
<input type="checkbox" name="secure_boot" id="secure_boot">
<label for="secure_boot">Secure Boot を有効にする</label>
<small style="margin-left:5px;color:var(--text-secondary)">Windows 11等で推奨。CachyOS等は非推奨</small>
</div>
<div class="checkbox-group">
<input type="checkbox" name="tpm_enabled" id="tpm_enabled">
<label for="tpm_enabled">TPM v2.0 を有効にする</label>
<small style="margin-left:5px;color:var(--text-secondary)">Windows 11等で推奨</small>
</div>
</div>
<div class="card">
<div class="card-header">
<h2><i class="fas fa-hdd"></i> ディスク</h2>
</div>
<div class="form-row">
<div class="form-group">
<label>プライマリディスク (GB) - 0で作成しない</label>
<select name="disk_size_gb">
<option value="0">作成しない</option>
{% for gb in [10,20,30,40,50,60,80,100,120,150,200,256,300,400,500,750,1024] %}
<option value="{{ gb }}" {% if gb == 50 %}selected{% endif %}>{{ gb }} GB</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label>ストレージプール</label>
<select name="disk_pool">
{% for pool in storage_pools %}
<option value="{{ pool.name }}" {% if pool.name == 'default' %}selected{% endif %}>{{ pool.name }}{% if pool.active %} ✓{% endif %}</option>
{% endfor %}
</select>
</div>
</div>
<div class="form-group">
<label>ディスクバス</label>
<select name="disk_bus">
<option value="virtio">virtio (推奨)</option>
<option value="sata">SATA</option>
<option value="scsi">SCSI</option>
</select>
</div>
<h3 style="margin-top:15px">追加ディスク</h3>
<div id="extra-disks"></div>
<div id="extra-disk-config" class="disk-item" style="margin-top:5px">
<div class="form-row-3">
<div class="form-group">
<label>タイプ</label>
<select id="cfg-extra-type" onchange="onCfgTypeChange()">
<option value="file">ファイル (qcow2/raw)</option>
<option value="block">ブロックデバイス</option>
</select>
</div>
<div class="form-group">
<label>ソースパス</label>
<div style="display:flex;gap:4px">
<select id="cfg-extra-source-select" style="flex:2" onchange="onCfgSourceSelect()">
<option value="">-- デバイスを選択 --</option>
</select>
<input type="text" id="cfg-extra-source" style="flex:1" placeholder="/path/to/file">
</div>
</div>
<div class="form-group">
<label>ターゲット</label>
<input type="text" id="cfg-extra-target" value="vdb">
</div>
</div>
<div class="form-row" style="align-items:end">
<div class="form-group">
<label>バス</label>
<select id="cfg-extra-bus">
<option value="virtio">virtio</option>
<option value="sata">SATA</option>
<option value="scsi">SCSI</option>
</select>
</div>
<div class="form-group">
<label>ドライバー</label>
<select id="cfg-extra-driver">
<option value="qcow2">qcow2</option>
<option value="raw">raw</option>
</select>
</div>
<div class="form-group" style="margin:0">
<button type="button" class="btn btn-primary btn-sm" onclick="addExtraDisk()"><i class="fas fa-plus"></i> 追加</button>
</div>
</div>
</div>
<h3 style="margin-top:15px"><i class="fas fa-compact-disc"></i> ISO / CD-ROM</h3>
<div id="iso-disks"></div>
<div style="display:flex;gap:8px;align-items:end;margin-top:8px">
<div class="form-group" style="flex:1;margin:0">
<label>プールから選択</label>
<select id="iso-pool-select">
<option value="">-- 選択してください --</option>
</select>
</div>
<div class="form-group" style="margin:0">
<label>ターゲット</label>
<input type="text" id="iso-cfg-target" value="sdc" style="width:60px">
</div>
<button type="button" class="btn btn-primary btn-sm" onclick="addIsoFromPool()" style="margin-bottom:2px"><i class="fas fa-plus"></i> 追加</button>
</div>
<div style="display:flex;gap:8px;align-items:end;margin-top:8px">
<div class="form-group" style="flex:1;margin:0">
<label>手動入力</label>
<input type="text" id="iso-cfg-path" placeholder="/path/to/file.iso">
</div>
<div class="form-group" style="margin:0">
<label>ターゲット</label>
<input type="text" id="iso-cfg-target-manual" value="sdc" style="width:60px">
</div>
<button type="button" class="btn btn-secondary btn-sm" onclick="addIsoManual()" style="margin-bottom:2px"><i class="fas fa-plus"></i> 追加</button>
</div>
</div>
<div class="card">
<div class="card-header">
<h2><i class="fas fa-sort-amount-up"></i> ブート順序</h2>
</div>
<p style="color:var(--text-secondary);margin-bottom:10px;font-size:0.9em">ブートデバイスの優先順位を設定します(上から順に試行されます)</p>
<div id="boot-order-list">
<div class="disk-item" style="display:flex;align-items:center;gap:8px">
<span style="color:var(--text-secondary)">1.</span>
<select name="boot_dev_1" class="boot-dev-select" onchange="updateBootOrderNumbers()">
<option value="hd" selected>ハードディスク (hd)</option>
<option value="cdrom">CD-ROM (cdrom)</option>
<option value="network">ネットワーク (network)</option>
<option value="usb">USB</option>
</select>
<button type="button" class="remove-btn" onclick="this.parentElement.remove();updateBootOrderNumbers()"><i class="fas fa-times"></i></button>
</div>
</div>
<button type="button" class="btn btn-primary btn-sm" onclick="addBootDev()" style="margin-top:8px"><i class="fas fa-plus"></i> ブートデバイス追加</button>
</div>
<div class="card">
<div class="card-header">
<h2><i class="fas fa-network-wired"></i> ネットワーク</h2>
</div>
<div class="form-row">
<div class="form-group">
<label>ネットワークタイプ</label>
<select name="net_type">
<option value="network">Libvirt仮想ネットワーク</option>
<option value="bridge">ブリッジ</option>
<option value="direct">ダイレクト</option>
</select>
</div>
<div class="form-group">
<label>ネットワークソース</label>
<select name="net_source">
{% for net in networks %}
<option value="{{ net.name }}" {% if net.name == 'default' %}selected{% endif %}>{{ net.name }}{% if net.active %} ✓{% endif %}</option>
{% endfor %}
</select>
</div>
</div>
<div class="form-group">
<label>ネットワークモデル</label>
<select name="net_model">
<option value="virtio">virtio (推奨)</option>
<option value="e1000">E1000</option>
<option value="e1000e">E1000e</option>
<option value="rtl8139">RTL8139</option>
</select>
</div>
</div>
<div class="card">
<div class="card-header">
<h2><i class="fas fa-tv"></i> グラフィックス</h2>
</div>
<p style="color:var(--text-secondary);margin-bottom:12px;font-size:0.9em">VNCとSPICEを<strong>両方</strong>有効にできます</p>
<h3>VNC(常時有効)</h3>
<div class="form-row">
<div class="form-group">
<label>ポート(-1で自動)</label>
<input type="text" name="vnc_port" value="-1">
</div>
<div class="form-group">
<label>リッスンアドレス</label>
<input type="text" name="vnc_listen" value="0.0.0.0">
</div>
</div>
<div class="form-group">
<label>VNCパスワード(空なら認証なし)</label>
<input type="password" name="vnc_passwd" placeholder="空なら認証なし">
</div>
<h3 style="margin-top:15px">SPICE</h3>
<div class="checkbox-group">
<input type="checkbox" name="spice_enabled" id="spice_enabled" checked>
<label for="spice_enabled">SPICEを有効にする</label>
</div>
<div id="spice-options">
<div class="form-row">
<div class="form-group">
<label>ポート(-1で自動)</label>
<input type="text" name="spice_port" value="-1">
</div>
<div class="form-group">
<label>TLSポート(空ならTLS無効)</label>
<input type="text" name="spice_tls_port" placeholder="空なら無効">
</div>
</div>
<div class="form-group">
<label>リッスンアドレス</label>
<input type="text" name="spice_listen" value="0.0.0.0">
</div>
</div>
</div>
<div class="card">
<h2><i class="fas fa-tv"></i> ビデオ</h2>
<div class="form-row">
<div class="form-group">
<label>ビデオモデル</label>
<select name="video_model">
<option value="">自動(SPICEならQXL、VNCならVirtIO)</option>
<option value="virtio">VirtIO GPU(推奨・VNC向け)</option>
<option value="qxl">QXL(推奨・SPICE向け)</option>
<option value="cirrus">cirrus(レガシー)</option>
<option value="none">デバイスなし</option>
</select>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h2><i class="fas fa-volume-up"></i> サウンド / チャンネル / USBリダイレクター</h2>
</div>
<div class="checkbox-group">
<input type="checkbox" name="sound_enabled" id="sound_enabled" checked>
<label for="sound_enabled">サウンドデバイス (ich9)</label>
<small style="margin-left:5px;color:var(--text-secondary)">SPICEオーディオに使用</small>
</div>
<div class="checkbox-group">
<input type="checkbox" name="channel_spice" id="channel_spice" checked>
<label for="channel_spice">SPICE Agent チャンネル</label>
<small style="margin-left:5px;color:var(--text-secondary)">クリップボード共有・モニタ自動調整に必要</small>
</div>
<div class="checkbox-group">
<input type="checkbox" name="usb_redirector_1" id="usb_redirector_1">
<label for="usb_redirector_1">USB リダイレクター 1</label>
<small style="margin-left:5px;color:var(--text-secondary)">SPICE経由でホストUSBを転送</small>
</div>
<div class="checkbox-group">
<input type="checkbox" name="usb_redirector_2" id="usb_redirector_2">
<label for="usb_redirector_2">USB リダイレクター 2</label>
<small style="margin-left:5px;color:var(--text-secondary)">2台目のUSB転送用</small>
</div>
</div>
<div class="card">
<div class="card-header">
<h2><i class="fas fa-plug"></i> PCIデバイスパススルー</h2>
</div>
{% if hostdevs %}
<p style="color:var(--text-secondary);margin-bottom:10px;font-size:0.9em">VFIOに設定されたデバイスを選択:</p>
{% for hd in hostdevs %}
<div class="checkbox-group">
<input type="checkbox" name="hostdev_{{ loop.index }}" value='{{ {"domain":"0x0000","bus":"0x00","slot":"0x00","function":"0x0"} | tojson }}' data-hd='{{ hd | tojson }}'>
<label>{{ hd.name }} (Vendor: {{ hd.vendor_id }}, Product: {{ hd.product_id }}) - {{ hd.description }}</label>
</div>
{% endfor %}
{% else %}
<p style="color:var(--text-secondary);font-size:0.9em"><i class="fas fa-info-circle"></i> VFIOデバイスが見つかりません。GPU等のパススルーにはIOMMUとVFIOの設定が必要です。</p>
{% endif %}
</div>
<div class="card">
<div class="card-header">
<h2><i class="fas fa-usb"></i> USBデバイスパースルー</h2>
</div>
<p style="color:var(--text-secondary);margin-bottom:10px;font-size:0.9em">ホストに接続されているUSBデバイスを選択してパースルーします(Ventoy等のUSBブートに使用)</p>
<div id="usb-devices-list">
{% for usb in usb_devices %}
<div class="checkbox-group">
<input type="checkbox" name="usb_device" value='{{ {"vendor_id": usb.vendor_id, "product_id": usb.product_id} | tojson }}'>
<label>{{ usb.label }}</label>
</div>
{% endfor %}
{% if not usb_devices %}
<p style="color:var(--text-secondary)">USBデバイスが検出されませんでした</p>
{% endif %}
</div>
</div>
<div class="card">
<div class="card-header">
<h2><i class="fas fa-code"></i> 生成されるXMLプレビュー</h2>
</div>
<pre id="xml-preview" style="background:var(--bg-dark);padding:15px;border-radius:6px;font-size:0.8em;overflow:auto;max-height:300px;color:var(--text-secondary)">フォーム入力後にここにXMLが表示されます</pre>
</div>
<div style="text-align:right;margin-top:20px;padding-bottom:30px">
<button type="button" class="btn btn-secondary" onclick="previewXml()" style="margin-right:10px"><i class="fas fa-eye"></i> XMLプレビュー</button>
<button type="submit" class="btn btn-primary" style="font-size:1em;padding:12px 30px"><i class="fas fa-save"></i> 仮想マシンを作成</button>
</div>
</form>
{% endblock %}
{% block scripts %}
<script>
let extraDiskCount = 0;
let isoDiskCount = 0;
let bootDevCount = 1;
let blockDevices = [];
let poolVolumes = [];
document.getElementById('spice_enabled').addEventListener('change', function() {
document.getElementById('spice-options').style.display = this.checked ? 'block' : 'none';
});
function addBootDev() {
bootDevCount++;
const existingIsos = [];
document.querySelectorAll('[name^="iso_path_"]').forEach(inp => {
if (inp.value && inp.value.trim()) existingIsos.push(inp.value.trim());
});
const hasUsb = document.querySelectorAll('[name="usb_device"]:checked').length > 0 ||
document.getElementById('usb_redirector_1').checked ||
document.getElementById('usb_redirector_2').checked;
let options = '<option value="hd">ハードディスク (hd)</option>';
if (existingIsos.length > 0) {
options += '<option value="cdrom">CD-ROM (cdrom)</option>';
}
options += '<option value="network">ネットワーク (network)</option>';
if (hasUsb) {
options += '<option value="usb">USB</option>';
}
const html = `
<div class="disk-item" style="display:flex;align-items:center;gap:8px">
<span class="boot-num" style="color:var(--text-secondary)">${bootDevCount}.</span>
<select name="boot_dev_${bootDevCount}" class="boot-dev-select" onchange="updateBootOrderNumbers()">
${options}
</select>
<button type="button" class="remove-btn" onclick="this.parentElement.remove();updateBootOrderNumbers()"><i class="fas fa-times"></i></button>
</div>`;
document.getElementById('boot-order-list').insertAdjacentHTML('beforeend', html);
}
function updateBootOrderNumbers() {
const items = document.querySelectorAll('#boot-order-list .disk-item');
items.forEach((item, idx) => {
const num = item.querySelector('.boot-num');
if (num) num.textContent = (idx + 1) + '.';
});
}
function addExtraDisk() {
extraDiskCount++;
const i = extraDiskCount;
const type = document.getElementById('cfg-extra-type').value;
const src = document.getElementById('cfg-extra-source').value.trim();
const target = document.getElementById('cfg-extra-target').value.trim() || 'vdb';
const bus = document.getElementById('cfg-extra-bus').value;
const driver = document.getElementById('cfg-extra-driver').value;
if (!src) { alert('ソースパスを入力してください'); extraDiskCount--; return; }
const typeLabel = type === 'file' ? 'ファイル' : 'ブロック';
const html = `
<div class="disk-item" id="extra-disk-${i}">
<button type="button" class="remove-btn" onclick="this.parentElement.remove()"><i class="fas fa-times"></i></button>
<input type="hidden" name="extra_type_${i}" value="${type}">
<input type="hidden" name="extra_source_${i}" value="${src}">
<input type="hidden" name="extra_target_${i}" value="${target}">
<input type="hidden" name="extra_bus_${i}" value="${bus}">
<input type="hidden" name="extra_driver_${i}" value="${driver}">
<div style="display:flex;align-items:center;gap:10px">
<span class="status-badge status-running">${typeLabel}</span>
<span style="flex:1;overflow:hidden;text-overflow:ellipsis">${src}</span>
<span style="color:var(--text-secondary)">${target}</span>
<span style="color:var(--text-secondary)">${bus}</span>
<span style="color:var(--text-secondary)">${driver}</span>
</div>
</div>`;
document.getElementById('extra-disks').insertAdjacentHTML('beforeend', html);
document.getElementById('cfg-extra-source').value = '';
}
function onCfgTypeChange() {
const type = document.getElementById('cfg-extra-type').value;
const srcSelect = document.getElementById('cfg-extra-source-select');
const srcInput = document.getElementById('cfg-extra-source');
if (type === 'file') {
srcSelect.style.display = 'none';
srcInput.style.display = '';
srcInput.placeholder = '/path/to/disk.qcow2';
} else {
srcSelect.style.display = '';
srcInput.style.display = '';
srcInput.placeholder = '/dev/sdb';
}
}
function onCfgSourceSelect() {
const sel = document.getElementById('cfg-extra-source-select');
const inp = document.getElementById('cfg-extra-source');
if (sel.value) inp.value = sel.value;
}
function getNextIsoTarget() {
const used = new Set();
document.querySelectorAll('[name^="iso_target_"]').forEach(inp => used.add(inp.value));
document.querySelectorAll('[name^="iso_path_"]').forEach(() => {});
for (let code = 'sdc'.charCodeAt(2); code <= 'sdz'.charCodeAt(0); code++) {
const dev = 'sd' + String.fromCharCode(code);
if (!used.has(dev)) return dev;
}
return 'sdc';
}
function addIsoFromPool() {
const sel = document.getElementById('iso-pool-select');
if (!sel || !sel.value) { alert('ISOファイルを選択してください'); return; }
isoDiskCount++;
const target = document.getElementById('iso-cfg-target').value.trim() || getNextIsoTarget();
const html = `
<div class="disk-item" id="iso-disk-${isoDiskCount}">
<button type="button" class="remove-btn" onclick="this.parentElement.remove()"><i class="fas fa-times"></i></button>
<input type="hidden" name="iso_path_${isoDiskCount}" value="${sel.value}">
<div style="display:flex;align-items:center;gap:10px">
<i class="fas fa-compact-disc" style="color:var(--text-secondary)"></i>
<span style="flex:1;overflow:hidden;text-overflow:ellipsis">${sel.value.split('/').pop()}</span>
<input type="text" name="iso_target_${isoDiskCount}" value="${target}" style="width:60px;text-align:center">
</div>
</div>`;
document.getElementById('iso-disks').insertAdjacentHTML('beforeend', html);
sel.value = '';
document.getElementById('iso-cfg-target').value = getNextIsoTarget();
}
function addIsoManual() {
const pathInput = document.getElementById('iso-cfg-path');
const path = pathInput.value.trim();
if (!path) { alert('ISOファイルパスを入力してください'); return; }
isoDiskCount++;
const target = document.getElementById('iso-cfg-target-manual').value.trim() || getNextIsoTarget();
const html = `
<div class="disk-item" id="iso-disk-${isoDiskCount}">
<button type="button" class="remove-btn" onclick="this.parentElement.remove()"><i class="fas fa-times"></i></button>
<input type="hidden" name="iso_path_${isoDiskCount}" value="${path}">
<div style="display:flex;align-items:center;gap:10px">
<i class="fas fa-compact-disc" style="color:var(--text-secondary)"></i>
<span style="flex:1;overflow:hidden;text-overflow:ellipsis">${path.split('/').pop()}</span>
<input type="text" name="iso_target_${isoDiskCount}" value="${target}" style="width:60px;text-align:center">
</div>
</div>`;
document.getElementById('iso-disks').insertAdjacentHTML('beforeend', html);
pathInput.value = '';
document.getElementById('iso-cfg-target-manual').value = getNextIsoTarget();
}
function loadIsoFiles() {
const sel = document.getElementById('iso-pool-select');
if (!sel) return;
fetch('/api/iso-files').then(r=>r.json()).then(isos => {
isos.sort((a,b) => a.name.localeCompare(b.name));
isos.forEach(iso => {
const opt = document.createElement('option');
opt.value = iso.path;
opt.textContent = iso.label;
sel.appendChild(opt);
});
});
}
function collectFormData() {
const fd = new FormData(document.getElementById('vm-form'));
const data = {
name: fd.get('name'),
domain_type: fd.get('domain_type'),
arch: fd.get('arch'),
machine: fd.get('machine'),
vcpus: fd.get('vcpus'),
memory_mb: fd.get('memory_mb'),
uefi: fd.has('uefi'),
secure_boot: fd.has('secure_boot'),
disk_size_gb: fd.get('disk_size_gb'),
disk_pool: fd.get('disk_pool'),
disk_bus: fd.get('disk_bus'),
net_type: fd.get('net_type'),
net_source: fd.get('net_source'),
net_model: fd.get('net_model'),
vnc_port: fd.get('vnc_port'),
vnc_listen: fd.get('vnc_listen'),
vnc_passwd: fd.get('vnc_passwd'),
spice_enabled: fd.has('spice_enabled'),
spice_port: fd.get('spice_port'),
spice_tls_port: fd.get('spice_tls_port'),
spice_listen: fd.get('spice_listen'),
video_model: fd.get('video_model'),
tpm_enabled: fd.has('tpm_enabled'),
sound_enabled: fd.has('sound_enabled'),
channel_spice: fd.has('channel_spice'),
usb_redirector_1: fd.has('usb_redirector_1'),
usb_redirector_2: fd.has('usb_redirector_2'),
boot_order: [],
disks: [],
iso_paths: [],
hostdevs: [],
usb_hostdevs: []
};
document.querySelectorAll('#boot-order-list .boot-dev-select').forEach(sel => {
if (sel.value) data.boot_order.push(sel.value);
});
for (let i = 1; i <= 50; i++) {
const src = fd.get('extra_source_' + i);
if (src) {
const edtype = fd.get('extra_type_' + i);
data.disks.push({
type: edtype,
source_file: edtype === 'file' ? src : '',
source_dev: edtype === 'block' ? src : '',
target_dev: fd.get('extra_target_' + i),
target_bus: fd.get('extra_bus_' + i),
driver_type: fd.get('extra_driver_' + i)
});
}
}
for (let i = 1; i <= 50; i++) {
const src = fd.get('iso_path_' + i);
if (src && src.trim()) {
const target = fd.get('iso_target_' + i) || '';
data.iso_paths.push({path: src.trim(), target: target});
}
}
document.querySelectorAll('[name^="hostdev_"]:checked').forEach(cb => {
try {
const hd = JSON.parse(cb.dataset.hd);
data.hostdevs.push({
domain: "0x0000",
bus: "0x00",
slot: "0x00",
function: "0x0"
});
} catch(e) {}
});
document.querySelectorAll('[name="usb_device"]:checked').forEach(cb => {
try {
data.usb_hostdevs.push(JSON.parse(cb.value));
} catch(e) {}
});
return data;
}
function buildXmlFromData(data) {
let xml = `<domain type="${data.domain_type}">\n`;
xml += ` <name>${data.name}</name>\n`;
xml += ` <memory unit='KiB'>${parseInt(data.memory_mb) * 1024}</memory>\n`;
xml += ` <currentMemory unit='KiB'>${parseInt(data.memory_mb) * 1024}</currentMemory>\n`;
xml += ` <vcpu placement='static'>${data.vcpus}</vcpu>\n`;
if (data.uefi) {
xml += ` <os firmware='efi'>\n`;
xml += ` <type arch='${data.arch}' machine='${data.machine}'>hvm</type>\n`;
xml += ` <firmware>\n`;
xml += ` <feature enabled='yes' name='enrolled-keys'/>\n`;
xml += ` <feature enabled='yes' name='secure-boot'/>\n`;
xml += ` </firmware>\n`;
xml += ` <loader readonly='yes' secure='yes' type='pflash' format='raw'>/usr/share/OVMF/OVMF_CODE_4M.ms.fd</loader>\n`;
xml += ` <nvram template='/usr/share/OVMF/OVMF_VARS_4M.ms.fd' templateFormat='raw' format='raw'>/var/lib/libvirt/qemu/nvram/${data.name}_VARS.fd</nvram>\n`;
if (data.boot_order && data.boot_order.length > 0) {
data.boot_order.forEach(dev => { xml += ` <boot dev='${dev}'/>\n`; });
} else {
xml += ` <boot dev='hd'/>\n`;
}
xml += ` <bootmenu enable='yes'/>\n`;
} else {
xml += ` <os>\n`;
xml += ` <type arch='${data.arch}' machine='${data.machine}'>hvm</type>\n`;
if (data.boot_order && data.boot_order.length > 0) {
data.boot_order.forEach(dev => { xml += ` <boot dev='${dev}'/>\n`; });
} else {
xml += ` <boot dev='hd'/>\n`;
}
}
xml += ` </os>\n`;
xml += ` <features><acpi/><apic/></features>\n`;
xml += ` <cpu mode='host-passthrough'/>\n`;
xml += ` <clock offset='utc'/>\n`;
xml += ` <devices>\n`;
if (data.disk_size_gb) {
xml += ` <disk type='volume' device='disk'>\n`;
xml += ` <driver name='qemu' type='qcow2'/>\n`;
xml += ` <source pool='${data.disk_pool}' volume='${data.name}.qcow2'/>\n`;
xml += ` <target dev='vda' bus='${data.disk_bus}'/>\n`;
xml += ` </disk>\n`;
}
const devLetters = 'bcdefghijklmnop';
let devIdx = 0;
data.disks.forEach(dc => {
if (dc.type === 'block') {
xml += ` <disk type='block' device='disk'>\n`;
xml += ` <driver name='qemu' type='${dc.driver_type}'/>\n`;
xml += ` <source dev='${dc.source_dev}'/>\n`;
xml += ` <target dev='${dc.target_dev}' bus='${dc.target_bus}'/>\n`;
xml += ` </disk>\n`;
} else {
xml += ` <disk type='file' device='disk'>\n`;
xml += ` <driver name='qemu' type='${dc.driver_type}'/>\n`;
xml += ` <source file='${dc.source_file}'/>\n`;
xml += ` <target dev='${dc.target_dev}' bus='${dc.target_bus}'/>\n`;
xml += ` </disk>\n`;
}
devIdx++;
});
let isoIdx = 0;
data.iso_paths.forEach(iso => {
const path = typeof iso === 'object' ? iso.path : iso;
const target = (typeof iso === 'object' && iso.target) ? iso.target : String.fromCharCode(99 + isoIdx);
xml += ` <disk type='file' device='cdrom'>\n`;
xml += ` <driver name='qemu' type='raw'/>\n`;
xml += ` <source file='${path}'/>\n`;
xml += ` <target dev='${target}' bus='sata'/>\n`;
xml += ` <readonly/>\n`;
xml += ` </disk>\n`;
isoIdx++;
});
xml += ` <graphics type='vnc' port='${data.vnc_port}' autoport='yes' listen='${data.vnc_listen}'>\n`;
xml += ` <listen type='address' address='${data.vnc_listen}'/>\n`;
xml += ` </graphics>\n`;
if (data.spice_enabled) {
xml += ` <graphics type='spice' port='${data.spice_port}' autoport='yes' listen='${data.spice_listen}'>\n`;
xml += ` <listen type='address' address='${data.spice_listen}'/>\n`;
xml += ` <image compression='off'/>\n`;
xml += ` <playback compression='on'/>\n`;
xml += ` <streaming mode='filter'/>\n`;
xml += ` <clipboard copypaste='yes'/>\n`;
xml += ` <filetransfer enable='yes'/>\n`;
xml += ` </graphics>\n`;
}
if (data.net_type === 'network') {
xml += ` <interface type='network'>\n`;
xml += ` <source network='${data.net_source}'/>\n`;
} else if (data.net_type === 'bridge') {
xml += ` <interface type='bridge'>\n`;
xml += ` <source bridge='${data.net_source}'/>\n`;
} else {
xml += ` <interface type='direct'>\n`;
xml += ` <source dev='${data.net_source}'/>\n`;
}
xml += ` <model type='${data.net_model}'/>\n`;
xml += ` </interface>\n`;
data.hostdevs.forEach(hd => {
xml += ` <hostdev mode='subsystem' type='pci' managed='yes'>\n`;
xml += ` <source>\n`;
xml += ` <address domain='${hd.domain}' bus='${hd.bus}' slot='${hd.slot}' function='${hd.function}'/>\n`;
xml += ` </source>\n`;
xml += ` </hostdev>\n`;
});
let videoModel = data.video_model;
if (!videoModel) {
videoModel = data.spice_enabled ? 'qxl' : 'virtio';
}
if (videoModel !== 'none') {
xml += ` <video>\n`;
if (videoModel === 'qxl') {
xml += ` <model type='qxl' ram='65536' vram='65536' vgamem='16384' heads='1'/>\n`;
} else {
xml += ` <model type='${videoModel}' heads='1'/>\n`;
}
xml += ` </video>\n`;
}
if (data.tpm_enabled) {
xml += ` <tpm model='tpm-crb'>\n`;
xml += ` <backend type='emulator'/>\n`;
xml += ` </tpm>\n`;
}
if (data.sound_enabled) {
xml += ` <sound model='ich9'/>\n`;
}
if (data.channel_spice) {
xml += ` <channel type='spicevmc'>\n`;
xml += ` <target type='virtio' name='com.redhat.spice.0'/>\n`;
xml += ` </channel>\n`;
}
if (data.usb_redirector_1) {
xml += ` <redirdev bus='usb' type='spicevmc'/>\n`;
}
if (data.usb_redirector_2) {
xml += ` <redirdev bus='usb' type='spicevmc'/>\n`;
}
xml += ` <memballoon model='virtio'/>\n`;
xml += ` </devices>\n`;
xml += ` <seclabel type='dynamic' model='apparmor' relabel='yes'/>\n`;
xml += `</domain>`;
return xml;
}
function previewXml() {
const data = collectFormData();
if (!data.name) {
alert('VM名を入力してください');
return;
}
document.getElementById('xml-preview').textContent = buildXmlFromData(data);
document.getElementById('xml-preview').scrollIntoView({behavior: 'smooth'});
}
document.getElementById('vm-form').addEventListener('submit', function(e) {
e.preventDefault();
const data = collectFormData();
if (!data.name) {
alert('VM名を入力してください');
return;
}
if (!confirm(`VM「${data.name}」を作成しますか?`)) return;
fetch('/vm/create', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(data)
})
.then(r => r.json())
.then(result => {
if (result.error) {
alert('エラー: ' + result.error);
} else {
alert('VM「' + data.name + '」を作成しました');
window.location.href = '/vm/' + data.name;
}
})
.catch(err => alert('通信エラー: ' + err));
});
document.addEventListener('DOMContentLoaded', function() {
loadIsoFiles();
loadBlockDevicesAndPoolVolumes();
onCfgTypeChange();
});
function loadBlockDevicesAndPoolVolumes() {
Promise.all([
fetch('/api/block-devices').then(r => r.json()),
fetch('/api/storage-pool-volumes').then(r => r.json())
]).then(([devices, volumes]) => {
blockDevices = devices || [];
poolVolumes = volumes || [];
const sel = document.getElementById('cfg-extra-source-select');
if (!sel) return;
blockDevices.forEach(d => {
const opt = document.createElement('option');
opt.value = d.path;
opt.textContent = d.label;
sel.appendChild(opt);
});
poolVolumes.forEach(v => {
const opt = document.createElement('option');
opt.value = v.path;
opt.textContent = v.label;
sel.appendChild(opt);
});
}).catch(() => {
blockDevices = [];
poolVolumes = [];
});
}
</script>
{% endblock %}
ENDOFFILE_VM_CREATE_HTML
# Write templates/vm_edit.html
cat << 'ENDOFFILE_VM_EDIT_HTML' > /opt/vm-manage/templates/vm_edit.html
{% extends "base.html" %}
{% block title %}{{ vm.name }} 編集 - VM Manager{% endblock %}
{% block content %}
<h1><i class="fas fa-edit"></i> {{ vm.name }} を編集</h1>
<p style="color:var(--text-secondary);margin-bottom:5px">停止中のVMの設定を変更できます</p>
{% if is_active %}
<div class="alert alert-error">VMが実行中です。編集するには先に停止してください。</div>
{% endif %}
<form id="edit-form">
<div class="card">
<h2><i class="fas fa-cog"></i> 基本設定</h2>
<div class="form-row">
<div class="form-group">
<label>仮想化タイプ</label>
<select name="domain_type" {% if is_active %}disabled{% endif %}>
<option value="kvm" {% if vm_config.domain_type == 'kvm' %}selected{% endif %}>KVM</option>
<option value="qemu" {% if vm_config.domain_type == 'qemu' %}selected{% endif %}>QEMU</option>
</select>
</div>
<div class="form-group">
<label>マシンタイプ</label>
<select name="machine" {% if is_active %}disabled{% endif %}>
<option value="pc-q35-10.2" {% if vm_config.machine == 'pc-q35-10.2' %}selected{% endif %}>pc-q35-10.2</option>
<option value="pc-q35-9.1" {% if vm_config.machine == 'pc-q35-9.1' %}selected{% endif %}>pc-q35-9.1</option>
<option value="pc-q35-9.0" {% if vm_config.machine == 'pc-q35-9.0' %}selected{% endif %}>pc-q35-9.0</option>
<option value="pc-i440fx-9.1" {% if vm_config.machine == 'pc-i440fx-9.1' %}selected{% endif %}>pc-i440fx-9.1</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>vCPU数</label>
<select name="vcpus" {% if is_active %}disabled{% endif %}>
{% for n in [1,2,4,6,8,12,16,24,32,48,64] %}
<option value="{{ n }}" {% if vm_config.vcpus|int == n %}selected{% endif %}>{{ n }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label>メモリ (MB)</label>
<select name="memory_mb" {% if is_active %}disabled{% endif %}>
{% for mb, label in [(512,'512 MB'),(1024,'1 GB'),(2048,'2 GB'),(3072,'3 GB'),(4096,'4 GB'),(6144,'6 GB'),(8192,'8 GB'),(12288,'12 GB'),(16384,'16 GB'),(24576,'24 GB'),(32768,'32 GB'),(49152,'48 GB'),(65536,'64 GB')] %}
<option value="{{ mb }}" {% if vm_config.memory_mb|int == mb %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="checkbox-group">
<input type="checkbox" name="uefi" id="uefi" {% if vm_config.uefi %}checked{% endif %} {% if is_active %}disabled{% endif %}>
<label for="uefi">UEFIブート (OVMF)</label>
</div>
<div class="checkbox-group">
<input type="checkbox" name="tpm_enabled" id="tpm_enabled" {% if vm_config.tpm_enabled %}checked{% endif %} {% if is_active %}disabled{% endif %}>
<label for="tpm_enabled">TPM v2.0 を有効にする</label>
</div>
<div class="checkbox-group" id="secureboot-group" style="{% if not vm_config.uefi %}display:none{% endif %}">
<input type="checkbox" name="secure_boot" id="secure_boot" {% if vm_config.secure_boot %}checked{% endif %} {% if is_active %}disabled{% endif %}>
<label for="secure_boot">Secure Boot</label>
<small style="margin-left:5px;color:var(--text-secondary)">Windows 11等で推奨。CachyOS等は非推奨</small>
</div>
</div>
<div class="card">
<div class="card-header">
<h2><i class="fas fa-hdd"></i> ディスク</h2>
</div>
<h3>現在のディスク</h3>
<table>
<thead><tr><th>デバイス</th><th>タイプ</th><th>ソース</th><th>バス</th></tr></thead>
<tbody id="existing-disks">
{% for d in devices.disks %}
<tr>
<td>{{ d.target_dev }}</td>
<td><span class="status-badge status-running">{{ d.type }}/{{ d.device }}</span></td>
<td>{{ d.source_file or d.source_dev or d.source_name or '-' }}</td>
<td>{{ d.target_bus }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<h3 style="margin-top:15px">ディスク追加</h3>
<div id="new-disks"></div>
<button type="button" class="btn btn-primary btn-sm" onclick="addNewDisk()" style="margin-top:5px"><i class="fas fa-plus"></i> ディスク追加</button>
<h3 style="margin-top:15px"><i class="fas fa-compact-disc"></i> ISO / CD-ROM</h3>
<div id="iso-disks"></div>
<div style="display:flex;gap:8px;align-items:end;margin-top:8px">
<div class="form-group" style="flex:1;margin:0">
<label>プールから選択</label>
<select id="iso-pool-select">
<option value="">-- 選択してください --</option>
</select>
</div>
<button type="button" class="btn btn-primary btn-sm" onclick="addIsoFromPool()" style="margin-bottom:2px"><i class="fas fa-plus"></i> 追加</button>
</div>
<button type="button" class="btn btn-secondary btn-sm" onclick="addIsoDisk()" style="margin-top:8px"><i class="fas fa-keyboard"></i> 手動入力</button>
</div>
<div class="card">
<div class="card-header">
<h2><i class="fas fa-tv"></i> グラフィックス</h2>
</div>
<p style="color:var(--text-secondary);margin-bottom:12px;font-size:0.9em">VNCとSPICEを<strong>両方</strong>有効にできます</p>
<h3>VNC</h3>
<div class="form-row">
<div class="form-group">
<label>ポート</label>
<input type="text" name="vnc_port" value="{{ vm_config.vnc_port }}">
</div>
<div class="form-group">
<label>リッスンアドレス</label>
<input type="text" name="vnc_listen" value="{{ vm_config.vnc_listen }}">
</div>
</div>
<h3 style="margin-top:15px">SPICE</h3>
<div class="checkbox-group">
<input type="checkbox" name="spice_enabled" id="spice_enabled" {% if vm_config.spice_enabled %}checked{% endif %}>
<label for="spice_enabled">SPICEを有効にする</label>
</div>
<div id="spice-options" style="{% if not vm_config.spice_enabled %}display:none{% endif %}">
<div class="form-row">
<div class="form-group">
<label>ポート</label>
<input type="text" name="spice_port" value="{{ vm_config.spice_port }}">
</div>
<div class="form-group">
<label>TLSポート</label>
<input type="text" name="spice_tls_port" value="">
</div>
</div>
<div class="form-group">
<label>リッスンアドレス</label>
<input type="text" name="spice_listen" value="{{ vm_config.spice_listen }}">
</div>
</div>
</div>
<div class="card">
<h2><i class="fas fa-tv"></i> ビデオ</h2>
<div class="form-row">
<div class="form-group">
<label>ビデオモデル</label>
<select name="video_model">
<option value="">自動</option>
<option value="virtio" {% if vm_config.video_model == 'virtio' %}selected{% endif %}>VirtIO GPU</option>
<option value="qxl" {% if vm_config.video_model == 'qxl' %}selected{% endif %}>QXL</option>
<option value="cirrus" {% if vm_config.video_model == 'cirrus' %}selected{% endif %}>cirrus</option>
<option value="none" {% if vm_config.video_model == 'none' %}selected{% endif %}>デバイスなし</option>
</select>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h2><i class="fas fa-network-wired"></i> ネットワーク</h2>
</div>
<div class="form-row">
<div class="form-group">
<label>ネットワークタイプ</label>
<select name="net_type" {% if is_active %}disabled{% endif %}>
{% for n in devices.networks %}
<option value="{{ n.type }}" selected>{{ n.type }}</option>
{% endfor %}
{% if not devices.networks %}
<option value="network">Libvirt仮想ネットワーク</option>
{% endif %}
</select>
</div>
<div class="form-group">
<label>ネットワークソース</label>
<select name="net_source" {% if is_active %}disabled{% endif %}>
{% for n in devices.networks %}
<option value="{{ n.source_network }}" selected>{{ n.source_network }}</option>
{% endfor %}
{% for net in networks %}
<option value="{{ net.name }}">{{ net.name }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="form-group">
<label>ネットワークモデル</label>
<select name="net_model" {% if is_active %}disabled{% endif %}>
{% for n in devices.networks %}
<option value="{{ n.model }}" selected>{{ n.model }}</option>
{% endfor %}
{% if not devices.networks %}
<option value="virtio">virtio</option>
{% endif %}
</select>
</div>
</div>
<div class="card">
<div class="card-header">
<h2><i class="fas fa-usb"></i> USBデバイスパースルー</h2>
</div>
{% if devices.hostdevs or devices.usb_hostdevs is defined and devices.usb_hostdevs %}
<h3>現在接続中のホストデバイス</h3>
<table>
<thead><tr><th>タイプ</th><th>詳細</th></tr></thead>
<tbody>
{% for h in devices.hostdevs %}
<tr><td>PCI</td><td>{{ h.domain }}:{{ h.bus }}:{{ h.slot }}.{{ h.function }}</td></tr>
{% endfor %}
{% for u in devices.usb_hostdevs %}
<tr><td>USB</td><td>{{ u.vendor_id }}:{{ u.product_id }}</td></tr>
{% endfor %}
</tbody>
</table>
{% endif %}
<h3 style="margin-top:15px">USBデバイス追加</h3>
<p style="color:var(--text-secondary);font-size:0.85em;margin-bottom:10px">ホストに接続されているUSBデバイスを選択してパースルーします</p>
<div id="usb-devices-list">
{% for usb in usb_devices %}
<div class="checkbox-group">
<input type="checkbox" name="usb_device" value='{{ {"vendor_id": usb.vendor_id, "product_id": usb.product_id} | tojson }}'>
<label>{{ usb.label }}</label>
</div>
{% endfor %}
{% if not usb_devices %}
<p style="color:var(--text-secondary)">USBデバイスが検出されませんでした</p>
{% endif %}
</div>
</div>
<div class="card">
<div class="card-header">
<h2><i class="fas fa-volume-up"></i> サウンド / チャンネル / USBリダイレクター</h2>
</div>
<div class="checkbox-group">
<input type="checkbox" name="sound_enabled" id="sound_enabled" {% if vm_config.sound_enabled %}checked{% endif %} {% if is_active %}disabled{% endif %}>
<label for="sound_enabled">サウンドデバイス (ich9)</label>
<small style="margin-left:5px;color:var(--text-secondary)">SPICEオーディオに使用</small>
</div>
<div class="checkbox-group">
<input type="checkbox" name="channel_spice" id="channel_spice" {% if vm_config.channel_spice %}checked{% endif %} {% if is_active %}disabled{% endif %}>
<label for="channel_spice">SPICE Agent チャンネル</label>
<small style="margin-left:5px;color:var(--text-secondary)">クリップボード共有・モニタ自動調整に必要</small>
</div>
<div class="checkbox-group">
<input type="checkbox" name="usb_redirector_1" id="usb_redirector_1" {% if vm_config.usb_redirector_1 %}checked{% endif %} {% if is_active %}disabled{% endif %}>
<label for="usb_redirector_1">USB リダイレクター 1</label>
<small style="margin-left:5px;color:var(--text-secondary)">SPICE経由でホストUSBを転送</small>
</div>
<div class="checkbox-group">
<input type="checkbox" name="usb_redirector_2" id="usb_redirector_2" {% if vm_config.usb_redirector_2 %}checked{% endif %} {% if is_active %}disabled{% endif %}>
<label for="usb_redirector_2">USB リダイレクター 2</label>
<small style="margin-left:5px;color:var(--text-secondary)">2台目のUSB転送用</small>
</div>
</div>
<div style="text-align:right;margin-top:20px;padding-bottom:30px">
<button type="button" class="btn btn-secondary" onclick="window.history.back()" style="margin-right:10px">キャンセル</button>
<button type="submit" class="btn btn-primary" style="font-size:1em;padding:12px 30px" {% if is_active %}disabled{% endif %}>
<i class="fas fa-save"></i> 設定を保存
</button>
</div>
</form>
{% endblock %}
{% block scripts %}
<script>
let newDiskCount = 0;
let isoDiskCount = 0;
document.getElementById('spice_enabled').addEventListener('change', function() {
document.getElementById('spice-options').style.display = this.checked ? 'block' : 'none';
});
function addNewDisk() {
newDiskCount++;
const html = `
<div class="disk-item" id="new-disk-${newDiskCount}">
<button type="button" class="remove-btn" onclick="this.parentElement.remove()"><i class="fas fa-times"></i></button>
<div class="form-row-3">
<div class="form-group">
<label>タイプ</label>
<select name="new_type_${newDiskCount}" onchange="updateDiskDefaults(this, ${newDiskCount})">
<option value="file">ファイル (qcow2/raw)</option>
<option value="block">ブロックデバイス</option>
</select>
</div>
<div class="form-group">
<label>ソースパス</label>
<input type="text" name="new_source_${newDiskCount}" placeholder="/dev/sdb">
</div>
<div class="form-group">
<label>ターゲット</label>
<input type="text" name="new_target_${newDiskCount}" value="vdb">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>バス</label>
<select name="new_bus_${newDiskCount}">
<option value="virtio">virtio</option>
<option value="sata">SATA</option>
<option value="scsi">SCSI</option>
</select>
</div>
<div class="form-group">
<label>ドライバー</label>
<select name="new_driver_${newDiskCount}">
<option value="qcow2">qcow2</option>
<option value="raw">raw</option>
</select>
</div>
</div>
</div>`;
document.getElementById('new-disks').insertAdjacentHTML('beforeend', html);
}
function updateDiskDefaults(sel, idx) {
const item = sel.closest('.disk-item');
const target = item.querySelector(`[name="new_target_${idx}"]`);
const bus = item.querySelector(`[name="new_bus_${idx}"]`);
const driver = item.querySelector(`[name="new_driver_${idx}"]`);
if (sel.value === 'block') {
target.value = 'vdb';
bus.value = 'virtio';
driver.value = 'raw';
} else {
target.value = 'vdb';
bus.value = 'virtio';
driver.value = 'qcow2';
}
}
function addIsoDisk() {
isoDiskCount++;
const html = `
<div class="disk-item" id="iso-disk-${isoDiskCount}">
<button type="button" class="remove-btn" onclick="this.parentElement.remove()"><i class="fas fa-times"></i></button>
<div class="form-row">
<div class="form-group">
<label>ISOファイルパス</label>
<input type="text" name="iso_path_${isoDiskCount}" placeholder="/var/lib/libvirt/images/win11.iso">
</div>
</div>
</div>`;
document.getElementById('iso-disks').insertAdjacentHTML('beforeend', html);
}
function addIsoFromPool() {
const sel = document.getElementById('iso-pool-select');
if (!sel || !sel.value) { alert('ISOファイルを選択してください'); return; }
isoDiskCount++;
const html = `
<div class="disk-item" id="iso-disk-${isoDiskCount}">
<button type="button" class="remove-btn" onclick="this.parentElement.remove()"><i class="fas fa-times"></i></button>
<div class="form-row">
<div class="form-group">
<label>ISOファイルパス</label>
<input type="text" name="iso_path_${isoDiskCount}" value="${sel.value}" readonly>
</div>
</div>
</div>`;
document.getElementById('iso-disks').insertAdjacentHTML('beforeend', html);
sel.value = '';
}
function loadIsoFiles() {
const sel = document.getElementById('iso-pool-select');
if (!sel) return;
fetch('/api/iso-files').then(r=>r.json()).then(isos => {
isos.sort((a,b) => a.name.localeCompare(b.name));
isos.forEach(iso => {
const opt = document.createElement('option');
opt.value = iso.path;
opt.textContent = iso.label;
sel.appendChild(opt);
});
});
}
function collectFormData() {
const fd = new FormData(document.getElementById('edit-form'));
const data = {
domain_type: fd.get('domain_type'),
arch: '{{ vm_config.arch }}',
machine: fd.get('machine'),
vcpus: fd.get('vcpus'),
memory_mb: fd.get('memory_mb'),
uefi: fd.has('uefi'),
vnc_port: fd.get('vnc_port'),
vnc_listen: fd.get('vnc_listen'),
spice_enabled: fd.has('spice_enabled'),
spice_port: fd.get('spice_port'),
spice_tls_port: fd.get('spice_tls_port'),
spice_listen: fd.get('spice_listen'),
video_model: fd.get('video_model'),
tpm_enabled: fd.has('tpm_enabled'),
secure_boot: fd.has('secure_boot'),
sound_enabled: fd.has('sound_enabled'),
channel_spice: fd.has('channel_spice'),
usb_redirector_1: fd.has('usb_redirector_1'),
usb_redirector_2: fd.has('usb_redirector_2'),
net_type: fd.get('net_type'),
net_source: fd.get('net_source'),
net_model: fd.get('net_model'),
existing_disks: [
{% for d in devices.disks %}
{
type: '{{ d.type }}',
device: '{{ d.device }}',
target_dev: '{{ d.target_dev }}',
target_bus: '{{ d.target_bus }}',
source_file: '{{ d.source_file }}',
source_dev: '{{ d.source_dev }}',
source_name: '{{ d.source_name }}',
source_protocol: '{{ d.source_protocol }}',
driver_type: '{{ d.driver_type }}',
readonly: {{ 'true' if d.device == 'cdrom' else 'false' }}
},
{% endfor %}
],
existing_usbs: [
{% for u in devices.usb_hostdevs %}
{
vendor_id: '{{ u.vendor_id }}',
product_id: '{{ u.product_id }}'
},
{% endfor %}
],
disks: [],
iso_paths: [],
hostdevs: [],
usb_hostdevs: []
};
for (let i = 1; i <= 50; i++) {
const src = fd.get('new_source_' + i);
if (src) {
const dtype = fd.get('new_type_' + i);
data.disks.push({
type: dtype,
source_file: dtype === 'file' ? src : '',
source_dev: dtype === 'block' ? src : '',
target_dev: fd.get('new_target_' + i),
target_bus: fd.get('new_bus_' + i),
driver_type: fd.get('new_driver_' + i)
});
}
}
for (let i = 1; i <= 50; i++) {
const src = fd.get('iso_path_' + i);
if (src && src.trim()) {
data.iso_paths.push(src.trim());
}
}
document.querySelectorAll('[name="usb_device"]:checked').forEach(cb => {
try {
data.usb_hostdevs.push(JSON.parse(cb.value));
} catch(e) {}
});
return data;
}
document.getElementById('edit-form').addEventListener('submit', function(e) {
e.preventDefault();
const data = collectFormData();
if (!confirm('設定を保存しますか?\n(VMの定義が更新されます)')) return;
fetch('/vm/{{ vm.name }}/edit', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(data)
})
.then(r => r.json())
.then(result => {
if (result.error) {
alert('エラー: ' + result.error);
} else {
alert('設定を保存しました');
window.location.href = '/vm/{{ vm.name }}';
}
})
.catch(err => alert('通信エラー: ' + err));
});
document.addEventListener('DOMContentLoaded', function() {
loadIsoFiles();
});
</script>
{% endblock %}
ENDOFFILE_VM_EDIT_HTML
# Write templates/vm_detail.html
cat << 'ENDOFFILE_VM_DETAIL_HTML' > /opt/vm-manage/templates/vm_detail.html
{% extends "base.html" %}
{% block title %}{{ vm.name }} - VM Manager{% endblock %}
{% block content %}
<h1>
<i class="fas fa-desktop"></i> {{ vm.name }}
<span class="status-badge status-{{ vm.state }}" style="margin-left:10px">{{ vm.state }}</span>
<span class="status-badge" style="background:rgba(52,152,219,0.2);color:var(--info);margin-left:5px">{{ vm.domain_type|upper }}</span>
</h1>
<div class="btn-group" style="margin-bottom:20px">
{% if vm.state == 'stopped' %}
<button class="btn btn-success" onclick="vmAction('start')"><i class="fas fa-play"></i> 起動</button>
{% else %}
<button class="btn btn-warning" onclick="vmAction('stop')"><i class="fas fa-stop"></i> 停止</button>
<button class="btn btn-danger" onclick="vmAction('destroy')"><i class="fas fa-power-off"></i> 強制停止</button>
<button class="btn btn-info" onclick="vmAction('reboot')"><i class="fas fa-redo"></i> 再起動</button>
<button class="btn btn-secondary" onclick="vmAction('suspend')"><i class="fas fa-pause"></i> 一時停止</button>
{% endif %}
<button class="btn btn-secondary" onclick="vmAction('resume')"><i class="fas fa-play-circle"></i> 再開</button>
<button class="btn btn-danger btn-sm" onclick="vmAction('undefine')"><i class="fas fa-trash"></i> 削除</button>
</div>
{% if is_active %}
<div style="margin-bottom:20px">
<div class="card" style="padding:0;overflow:hidden">
<div id="vnc-console-header" style="display:flex;align-items:center;gap:8px;padding:8px 12px;background:var(--bg-dark);border-bottom:1px solid var(--border)">
<i class="fas fa-terminal" style="color:var(--text-secondary)"></i>
<span style="flex:1;font-size:0.9em">VNC Console</span>
<span id="vnc-status" style="font-size:0.8em;padding:2px 8px;border-radius:3px;background:#27ae60;color:#fff">接続済み</span>
<button type="button" id="vnc-btn-fullscreen" style="background:none;border:1px solid #555;color:#ccc;padding:2px 8px;border-radius:3px;cursor:pointer;font-size:0.8em">全体</button>
<button type="button" id="vnc-btn-refresh" onclick="cleanupRfb();connectVnc();" style="background:#e94560;border:none;color:#fff;padding:4px 12px;border-radius:4px;cursor:pointer;font-size:0.85em;font-weight:bold"><i class="fas fa-sync-alt"></i> 再接続</button>
</div>
<div id="vnc-container" style="width:100%;height:500px;background:#000;display:flex;align-items:center;justify-content:center;position:relative">
</div>
</div>
</div>
{% endif %}
<div class="card" id="snapshots-card">
<div class="card-header">
<h2><i class="fas fa-camera"></i> スナップショット</h2>
<button class="btn btn-primary btn-sm" onclick="createSnapshot()"><i class="fas fa-plus"></i> 作成</button>
</div>
<div id="snapshot-list" style="color:var(--text-secondary)">読み込み中...</div>
</div>
<form id="vm-form">
<div class="card">
<h2><i class="fas fa-cog"></i> 基本設定</h2>
<div class="form-row">
<div class="form-group">
<label>仮想化タイプ</label>
<select name="domain_type" {% if is_active %}disabled{% endif %}>
<option value="kvm" {% if os_info.domain_type == 'kvm' %}selected{% endif %}>KVM</option>
<option value="qemu" {% if os_info.domain_type == 'qemu' %}selected{% endif %}>QEMU</option>
</select>
</div>
<div class="form-group">
<label>マシンタイプ</label>
<select name="machine" {% if is_active %}disabled{% endif %}>
{% for m in ['pc-q35-10.2','pc-q35-9.1','pc-q35-9.0','pc-i440fx-9.1','virt'] %}
<option value="{{ m }}" {% if os_info.machine == m %}selected{% endif %}>{{ m }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>vCPU数</label>
<select name="vcpus" {% if is_active %}disabled{% endif %}>
{% for n in [1,2,4,6,8,12,16,24,32,48,64] %}
<option value="{{ n }}" {% if vm.vcpus|int == n %}selected{% endif %}>{{ n }}</option>
{% endfor %}
</select>
</div>
<div class="form-group">
<label>メモリ (MB)</label>
<select name="memory_mb" {% if is_active %}disabled{% endif %}>
{% for mb, label in [(512,'512 MB'),(1024,'1 GB'),(2048,'2 GB'),(3072,'3 GB'),(4096,'4 GB'),(6144,'6 GB'),(8192,'8 GB'),(12288,'12 GB'),(16384,'16 GB'),(24576,'24 GB'),(32768,'32 GB'),(49152,'48 GB'),(65536,'64 GB')] %}
<option value="{{ mb }}" {% if vm.memory_mb|int == mb %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="checkbox-group">
<input type="checkbox" name="uefi" id="uefi" {% if vm_config.uefi %}checked{% endif %} {% if is_active %}disabled{% endif %}>
<label for="uefi">UEFIブート (OVMF)</label>
</div>
<div class="checkbox-group">
<input type="checkbox" name="tpm_enabled" id="tpm_enabled" {% if vm_config.tpm_enabled %}checked{% endif %} {% if is_active %}disabled{% endif %}>
<label for="tpm_enabled">TPM v2.0</label>
</div>
<div class="checkbox-group" id="secureboot-group" style="{% if not vm_config.uefi %}display:none{% endif %}">
<input type="checkbox" name="secure_boot" id="secure_boot" {% if vm_config.secure_boot %}checked{% endif %} {% if is_active %}disabled{% endif %}>
<label for="secure_boot">Secure Boot</label>
<small style="margin-left:5px;color:var(--text-secondary)">Windows 11等で推奨。CachyOS等は非推奨</small>
</div>
</div>
<div class="card">
<h2><i class="fas fa-hdd"></i> ディスク</h2>
{% if devices.disks %}
<table>
<thead><tr><th>デバイス</th><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>
<td style="white-space:nowrap">
{% if d.target_dev %}
{% if d.device == 'cdrom' %}
<button type="button" class="btn btn-info btn-sm" onclick="changeIso('{{ d.target_dev }}')" title="ISO変更"><i class="fas fa-exchange-alt"></i></button>
{% endif %}
{% if d.driver_type == 'qcow2' and not is_active %}
<button type="button" class="btn btn-warning btn-sm" onclick="resizeDisk('{{ d.target_dev }}', '{{ d.source_file }}')" title="ディスク拡大"><i class="fas fa-expand-arrows-alt"></i></button>
{% endif %}
{% if not is_active %}
<button type="button" class="btn btn-danger btn-sm" onclick="detachDisk('{{ d.target_dev }}', '{{ d.device }}')" title="削除"><i class="fas fa-trash"></i></button>
{% endif %}
{% endif %}
</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-usb"></i> USBデバイスパススルー</h2>
</div>
<p style="color:var(--text-secondary);font-size:0.85em;margin-bottom:10px">ホストUSBデバイスをVMに接続・取り外しできます</p>
<div id="usb-attached-list"></div>
<h3 style="margin-top:10px">ホストUSBデバイス</h3>
<div id="usb-host-list"></div>
</div>
<div class="card">
<div class="card-header">
<h2><i class="fas fa-tv"></i> グラフィックス</h2>
</div>
<p style="color:var(--text-secondary);margin-bottom:12px;font-size:0.9em">VNCとSPICEを<strong>両方</strong>有効にできます</p>
<h3>VNC</h3>
<div class="form-row">
<div class="form-group">
<label>ポート</label>
<input type="text" name="vnc_port" value="{{ vm_config.vnc_port }}" {% if is_active %}disabled{% endif %}>
</div>
<div class="form-group">
<label>リッスン</label>
<input type="text" name="vnc_listen" value="{{ vm_config.vnc_listen }}" {% if is_active %}disabled{% endif %}>
</div>
</div>
<h3 style="margin-top:15px">SPICE</h3>
<div class="checkbox-group">
<input type="checkbox" name="spice_enabled" id="spice_enabled" {% if vm_config.spice_enabled %}checked{% endif %} {% if is_active %}disabled{% endif %}>
<label for="spice_enabled">SPICEを有効にする</label>
</div>
<div id="spice-options" style="{% if not vm_config.spice_enabled %}display:none{% endif %}">
<div class="form-row">
<div class="form-group">
<label>ポート</label>
<input type="text" name="spice_port" value="{{ vm_config.spice_port }}" {% if is_active %}disabled{% endif %}>
</div>
<div class="form-group">
<label>TLSポート</label>
<input type="text" name="spice_tls_port" value="" {% if is_active %}disabled{% endif %}>
</div>
</div>
<div class="form-group">
<label>リッスン</label>
<input type="text" name="spice_listen" value="{{ vm_config.spice_listen }}" {% if is_active %}disabled{% endif %}>
</div>
</div>
</div>
<div class="card">
<h2><i class="fas fa-tv"></i> ビデオ</h2>
<div class="form-group">
<label>ビデオモデル</label>
<select name="video_model" {% if is_active %}disabled{% endif %}>
<option value="">自動</option>
{% for v in [('virtio','VirtIO GPU'),('qxl','QXL'),('cirrus','cirrus'),('none','デバイスなし')] %}
<option value="{{ v[0] }}" {% if vm_config.video_model == v[0] %}selected{% endif %}>{{ v[1] }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="card">
<div class="card-header">
<h2><i class="fas fa-network-wired"></i> ネットワーク</h2>
</div>
<div class="form-row">
<div class="form-group">
<label>ネットワークタイプ</label>
<select name="net_type" {% if is_active %}disabled{% endif %}>
{% for n in devices.networks %}
<option value="{{ n.type }}" selected>{{ n.type }}</option>
{% endfor %}
{% if not devices.networks %}
<option value="network">Libvirt仮想ネットワーク</option>
{% endif %}
</select>
</div>
<div class="form-group">
<label>ネットワークソース</label>
<select name="net_source" {% if is_active %}disabled{% endif %}>
{% for n in devices.networks %}
<option value="{{ n.source_network }}" selected>{{ n.source_network }}</option>
{% endfor %}
{% for net in networks %}
<option value="{{ net.name }}">{{ net.name }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="form-group">
<label>ネットワークモデル</label>
<select name="net_model" {% if is_active %}disabled{% endif %}>
{% for n in devices.networks %}
<option value="{{ n.model }}" selected>{{ n.model }}</option>
{% endfor %}
{% if not devices.networks %}
<option value="virtio">virtio</option>
{% endif %}
</select>
</div>
</div>
<div class="card">
<div class="card-header">
<h2><i class="fas fa-volume-up"></i> サウンド / チャンネル / USBリダイレクター</h2>
</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 %}
<div style="display:flex;gap:8px;align-items:center;margin-top:10px">
<select id="boot-add-select" style="padding:4px 8px;border-radius:4px;background:var(--bg-dark);color:var(--text-primary);border:1px solid var(--border)">
<option value="hd">ハードディスク</option>
<option value="cdrom">CD-ROM / ISO</option>
<option value="network">ネットワーク</option>
</select>
<button type="button" class="btn btn-primary btn-sm" onclick="addBootDevice()"><i class="fas fa-plus"></i> 追加</button>
<button type="button" class="btn btn-primary btn-sm" onclick="saveBootOrder()" style="margin-left:auto"><i class="fas fa-save"></i> 保存</button>
</div>
{% 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}" onchange="updateDiskDefaults(this, ${newDiskCount})">
<option value="file">ファイル</option>
<option value="block">ブロックデバイス</option>
</select>
</div>
<div class="form-group">
<label>ソースパス</label>
<input type="text" name="new_source_${newDiskCount}" placeholder="/dev/sdb または /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 updateDiskDefaults(sel, idx) {
const item = sel.closest('.disk-item');
const target = item.querySelector(`[name="new_target_${idx}"]`);
const bus = item.querySelector(`[name="new_bus_${idx}"]`);
const driver = item.querySelector(`[name="new_driver_${idx}"]`);
if (sel.value === 'block') {
target.value = 'vdb';
bus.value = 'virtio';
driver.value = 'raw';
} else {
target.value = 'vdb';
bus.value = 'virtio';
driver.value = 'qcow2';
}
}
function addIsoDisk() {
isoDiskCount++;
const html = `
<div class="disk-item" id="iso-disk-${isoDiskCount}">
<button type="button" class="remove-btn" onclick="this.parentElement.remove()"><i class="fas fa-times"></i></button>
<div class="form-row">
<div class="form-group">
<label>ISOファイルパス</label>
<input type="text" name="iso_path_${isoDiskCount}" placeholder="/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'));
const bootOrder = [];
const bootItems = document.querySelectorAll('#boot-order-items .disk-item');
bootItems.forEach(it => { if (it.dataset.dev) bootOrder.push(it.dataset.dev); });
const newUsbs = [];
document.querySelectorAll('[name="usb_device"]:checked').forEach(cb => {
try { newUsbs.push(JSON.parse(cb.value)); } catch(e) {}
});
return {
domain_type: fd.get('domain_type'),
arch: '{{ os_info.arch }}',
machine: fd.get('machine'),
vcpus: fd.get('vcpus'),
memory_mb: fd.get('memory_mb'),
uefi: fd.has('uefi'),
tpm_enabled: fd.has('tpm_enabled'),
secure_boot: fd.has('secure_boot'),
sound_enabled: fd.has('sound_enabled'),
channel_spice: fd.has('channel_spice'),
usb_redirector_1: fd.has('usb_redirector_1'),
usb_redirector_2: fd.has('usb_redirector_2'),
vnc_port: fd.get('vnc_port'),
vnc_listen: fd.get('vnc_listen'),
spice_enabled: fd.has('spice_enabled'),
spice_port: fd.get('spice_port'),
spice_tls_port: fd.get('spice_tls_port'),
spice_listen: fd.get('spice_listen'),
video_model: fd.get('video_model'),
net_type: fd.get('net_type'),
net_source: fd.get('net_source'),
net_model: fd.get('net_model'),
boot_order: bootOrder,
existing_disks: [
{% for d in devices.disks %}
{type:'{{ d.type }}',device:'{{ d.device }}',target_dev:'{{ d.target_dev }}',target_bus:'{{ d.target_bus }}',source_file:'{{ d.source_file }}',source_dev:'{{ d.source_dev }}',source_name:'{{ d.source_name }}',source_protocol:'{{ d.source_protocol }}',driver_type:'{{ d.driver_type }}',readonly:{{ 'true' if d.device == 'cdrom' else 'false' }}},
{% endfor %}
],
existing_usbs: [
{% for u in devices.usb_hostdevs %}
{vendor_id:'{{ u.vendor_id }}',product_id:'{{ u.product_id }}'},
{% endfor %}
],
disks: [],
iso_paths: [],
hostdevs: [],
usb_hostdevs: newUsbs
};
}
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 : '',
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 detachDisk(targetDev, deviceType) {
const label = deviceType === 'cdrom' ? 'CD-ROM' : 'ディスク';
if (!confirm(`${label} '${targetDev}' を削除しますか?`)) return;
fetch(`/api/vm/{{ vm.name }}/action`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({action: 'disk_detach', target_dev: targetDev})
})
.then(r => r.json())
.then(d => { if (d.error) alert('エラー: ' + d.error); else { alert('削除しました'); location.reload(); }})
.catch(err => alert('通信エラー: ' + err));
}
function changeIso(targetDev) {
let existing = document.getElementById('iso-change-modal');
if (existing) existing.remove();
const modal = document.createElement('div');
modal.id = 'iso-change-modal';
modal.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;z-index:1000';
modal.innerHTML = `
<div style="background:var(--bg-card);border-radius:8px;padding:20px;min-width:400px;max-width:90vw">
<h3 style="margin-top:0">CD-ROM '${targetDev}' のISO変更</h3>
<div class="form-group">
<label>プールから選択</label>
<select id="iso-change-select" style="width:100%">
<option value="">-- 選択してください --</option>
<option value="__eject__">ISOなし(取り出し)</option>
</select>
</div>
<div class="form-group">
<label>または手動入力</label>
<input type="text" id="iso-change-input" placeholder="/path/to/file.iso" style="width:100%">
</div>
<div style="text-align:right;margin-top:15px">
<button type="button" class="btn btn-secondary" onclick="document.getElementById('iso-change-modal').remove()" style="margin-right:8px">キャンセル</button>
<button type="button" class="btn btn-primary" onclick="confirmIsoChange('${targetDev}')">変更</button>
</div>
</div>`;
document.body.appendChild(modal);
fetch('/api/iso-files').then(r => r.json()).then(isos => {
const sel = document.getElementById('iso-change-select');
isos.sort((a, b) => a.name.localeCompare(b.name));
isos.forEach(iso => {
const opt = document.createElement('option');
opt.value = iso.path;
opt.textContent = iso.label;
sel.appendChild(opt);
});
});
document.getElementById('iso-change-select').addEventListener('change', function() {
if (this.value === '__eject__') {
document.getElementById('iso-change-input').value = '';
} else {
document.getElementById('iso-change-input').value = this.value;
}
});
}
function confirmIsoChange(targetDev) {
const input = document.getElementById('iso-change-input');
const sel = document.getElementById('iso-change-select');
let newSource = (input && input.value.trim()) || (sel && sel.value);
if (newSource === '__eject__') newSource = '';
if (!newSource && sel && sel.value !== '__eject__' && !input.value.trim()) { alert('ISOファイルを選択または入力してください'); return; }
const label = newSource ? 'ISOを変更' : 'ISOを取り出し';
if (!confirm(`CD-ROM '${targetDev}' を${label}しますか?`)) return;
document.getElementById('iso-change-modal').remove();
fetch(`/api/vm/{{ vm.name }}/action`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({action: 'disk_update_source', target_dev: targetDev, new_source: newSource})
})
.then(r => r.json())
.then(d => { if (d.error) alert('エラー: ' + d.error); else { alert(`${label}しました`); location.reload(); }})
.catch(err => alert('通信エラー: ' + err));
}
function resizeDisk(targetDev, sourcePath) {
let existing = document.getElementById('disk-resize-modal');
if (existing) existing.remove();
const fileName = sourcePath ? sourcePath.split('/').pop() : targetDev;
const modal = document.createElement('div');
modal.id = 'disk-resize-modal';
modal.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;z-index:1000';
modal.innerHTML = `
<div style="background:var(--bg-card);border-radius:8px;padding:20px;min-width:380px;max-width:90vw">
<h3 style="margin-top:0">ディスク拡大</h3>
<p style="color:var(--text-secondary);font-size:0.9em;margin-bottom:15px">
対象: <strong>${fileName}</strong><br>
パス: <span style="font-size:0.85em">${sourcePath || '-'}</span>
</p>
<div class="form-group">
<label>新しいサイズ</label>
<input type="text" id="disk-resize-input" placeholder="例: 100G, 200G" style="width:100%">
<small style="color:var(--text-secondary)">例: 100G (+100GiB), 500M (+500MiB)</small>
</div>
<div style="text-align:right;margin-top:15px">
<button type="button" class="btn btn-secondary" onclick="document.getElementById('disk-resize-modal').remove()" style="margin-right:8px">キャンセル</button>
<button type="button" class="btn btn-warning" onclick="confirmDiskResize('${targetDev}')">拡大</button>
</div>
</div>`;
document.body.appendChild(modal);
}
function confirmDiskResize(targetDev) {
const input = document.getElementById('disk-resize-input');
const newSize = input ? input.value.trim() : '';
if (!newSize) { alert('拡大するサイズを入力してください'); return; }
if (!confirm(`ディスク '${targetDev}' を ${newSize} に拡大しますか?`)) return;
document.getElementById('disk-resize-modal').remove();
fetch(`/api/vm/{{ vm.name }}/action`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({action: 'disk_resize', target_dev: targetDev, new_size: newSize})
})
.then(r => r.json())
.then(d => {
if (d.error) {
alert('エラー: ' + d.error);
} else {
const oldGB = d.old_size ? (d.old_size / 1073741824).toFixed(1) : '?';
const newGB = d.new_size ? (d.new_size / 1073741824).toFixed(1) : '?';
alert(`拡大しました!\\n${oldGB} GB → ${newGB} GB\\n\\nゲストOS内でもパーティションを拡張してください。`);
location.reload();
}
})
.catch(err => alert('通信エラー: ' + err));
}
function usbAttach(vid, pid, label) {
if (!confirm(`${label} をVMに接続しますか?`)) return;
fetch(`/api/vm/{{ vm.name }}/action`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({action:'usb_attach', vendor_id:vid, product_id:pid})
})
.then(r => r.json())
.then(d => { if (d.error) alert('エラー: ' + d.error); else { alert('接続しました'); loadUsbDevices(); }})
.catch(err => alert('通信エラー: ' + err));
}
function usbDetach(vid, pid, label) {
if (!confirm(`${label} をVMから取り外しますか?`)) return;
fetch(`/api/vm/{{ vm.name }}/action`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({action:'usb_detach', vendor_id:vid, product_id:pid})
})
.then(r => r.json())
.then(d => { if (d.error) alert('エラー: ' + d.error); else { alert('取り外しました'); loadUsbDevices(); }})
.catch(err => alert('通信エラー: ' + err));
}
function loadUsbDevices() {
const isActive = {{ 'true' if is_active else 'false' }};
Promise.all([
fetch(`/api/vm/{{ vm.name }}/xml`).then(r=>r.json()),
fetch('/api/usb-devices').then(r=>r.json())
]).then(([xmlData, hostDevs]) => {
const doc = new DOMParser().parseFromString(xmlData.xml, 'text/xml');
const usbs = doc.querySelectorAll('hostdev[type="usb"]');
const nameMap = {};
hostDevs.forEach(d => { nameMap[d.vendor_id.toLowerCase() + ':' + d.product_id.toLowerCase()] = d.name; });
let html = '';
if (usbs.length) {
html = '<table><thead><tr><th>デバイス</th><th></th></tr></thead><tbody>';
usbs.forEach(hd => {
const v = hd.querySelector('vendor')?.getAttribute('id') || '';
const p = hd.querySelector('product')?.getAttribute('id') || '';
const key = v.replace('0x','').toLowerCase() + ':' + p.replace('0x','').toLowerCase();
const devName = nameMap[key] || '不明';
html += `<tr><td>${v}:${p} <span style="color:var(--text-secondary)">(${devName})</span></td>`;
html += `<td><button type="button" class="btn btn-danger btn-sm" onclick="usbDetach('${v.replace('0x','')}','${p.replace('0x','')}','${v}:${p}')"><i class="fas fa-times"></i> 取り外し</button></td>`;
html += '</tr>';
});
html += '</tbody></table>';
} else {
html = '<p style="color:var(--text-secondary)">接続中のUSBデバイスはありません</p>';
}
document.getElementById('usb-attached-list').innerHTML = html;
});
fetch('/api/usb-devices').then(r=>r.json()).then(devs => {
let html = '';
devs.forEach(d => {
html += `<div style="display:flex;align-items:center;gap:10px;margin:4px 0"><span style="font-size:0.9em">${d.label}</span>`;
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>`;
html += '</div>';
});
document.getElementById('usb-host-list').innerHTML = html || '<p style="color:var(--text-secondary)">USBデバイスなし</p>';
});
}
function loadBootOrder() {
Promise.all([
fetch(`/api/vm/{{ vm.name }}/bootorder`).then(r=>r.json()),
fetch(`/api/vm/{{ vm.name }}/xml`).then(r=>r.json())
]).then(([bootData, xmlData]) => {
const doc = new DOMParser().parseFromString(xmlData.xml, 'text/xml');
const cdroms = [...doc.querySelectorAll('disk[device="cdrom"]')].map(d => {
const src = d.querySelector('source');
const tgt = d.querySelector('target');
return { dev: tgt?.getAttribute('dev') || '', file: src?.getAttribute('file') || '' };
});
let html = '';
const existingDevs = new Set();
if (bootData.boot_order && bootData.boot_order.length) {
html = '<div id="boot-order-items">';
const icons = {hd:'\uD83D\uDCBE',cdrom:'\uD83D\uDCBF',network:'\uD83C\uDF10',usb:'\uD83D\uDD0C'};
bootData.boot_order.forEach((item, i) => {
existingDevs.add(item.dev);
let label = item.dev;
if (item.dev === 'cdrom' && cdroms.length) {
label = 'CD-ROM (' + cdroms.map(c => c.file.split('/').pop()).join(', ') + ')';
}
html += `<div class="disk-item" style="display:flex;align-items:center;gap:10px;padding:8px 12px" data-dev="${item.dev}">`;
html += `<span style="color:var(--text-secondary);font-weight:bold">${i+1}.</span>`;
html += `<span>${icons[item.dev]||'\u2753'} ${label}</span>`;
html += `<span style="flex:1"></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 < bootData.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 += `<button type="button" class="btn btn-danger btn-sm" onclick="removeBoot(${i})"><i class="fas fa-times"></i></button>`;
html += '</div>';
});
html += '</div>';
} else {
html = '<p style="color:var(--text-secondary)">ブート順序なし</p>';
}
document.getElementById('boot-order-list').innerHTML = html;
const sel = document.getElementById('boot-add-select');
if (sel) {
[...sel.options].forEach(opt => {
opt.disabled = existingDevs.has(opt.value);
});
}
}).catch(() => {
document.getElementById('boot-order-list').innerHTML = '<p style="color:var(--text-secondary)">取得できません</p>';
});
}
function addBootDevice() {
const sel = document.getElementById('boot-add-select');
if (!sel || !sel.value) return;
const dev = sel.value;
const container = document.getElementById('boot-order-items') || (() => {
const d = document.createElement('div');
d.id = 'boot-order-items';
document.getElementById('boot-order-list').appendChild(d);
return d;
})();
const idx = container.children.length;
const icons = {hd:'\uD83D\uDCBE',cdrom:'\uD83D\uDCBF',network:'\uD83C\uDF10',usb:'\uD83D\uDD0C'};
const labels = {hd:'ハードディスク',cdrom:'CD-ROM / ISO',network:'ネットワーク',usb:'USB'};
const div = document.createElement('div');
div.className = 'disk-item';
div.style.cssText = 'display:flex;align-items:center;gap:10px;padding:8px 12px';
div.dataset.dev = dev;
div.innerHTML = `<span style="color:var(--text-secondary);font-weight:bold">${idx+1}.</span><span>${icons[dev]||'\u2753'} ${labels[dev]||dev}</span><span style="flex:1"></span><button type="button" class="btn btn-secondary btn-sm" onclick="moveBoot(${idx},-1)"><i class="fas fa-arrow-up"></i></button><button type="button" class="btn btn-secondary btn-sm" onclick="moveBoot(${idx},1)"><i class="fas fa-arrow-down"></i></button><button type="button" class="btn btn-danger btn-sm" onclick="removeBoot(${idx})"><i class="fas fa-times"></i></button>`;
container.appendChild(div);
sel.options[sel.selectedIndex].disabled = true;
if (sel.options.length > 0) sel.selectedIndex = 0;
updateBootNumbers();
}
function removeBoot(idx) {
const c = document.getElementById('boot-order-items');
if (!c || !c.children[idx]) return;
const dev = c.children[idx].dataset.dev;
c.children[idx].remove();
updateBootNumbers();
const sel = document.getElementById('boot-add-select');
if (sel) {
[...sel.options].forEach(opt => {
if (opt.value === dev) opt.disabled = false;
});
}
}
function updateBootNumbers() {
const c = document.getElementById('boot-order-items');
if (!c) return;
[...c.children].forEach((it, i) => {
const num = it.querySelector('span:first-child');
if (num) num.textContent = (i+1) + '.';
const btns = it.querySelectorAll('button');
btns.forEach(b => b.remove());
it.innerHTML += `<span style="flex:1"></span>`;
if (i > 0) it.innerHTML += `<button type="button" class="btn btn-secondary btn-sm" onclick="moveBoot(${i},-1)"><i class="fas fa-arrow-up"></i></button>`;
if (i < c.children.length-1) it.innerHTML += `<button type="button" class="btn btn-secondary btn-sm" onclick="moveBoot(${i},1)"><i class="fas fa-arrow-down"></i></button>`;
it.innerHTML += `<button type="button" class="btn btn-danger btn-sm" onclick="removeBoot(${i})"><i class="fas fa-times"></i></button>`;
});
}
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]);
updateBootNumbers();
}
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();
loadSnapshots();
});
function loadSnapshots() {
fetch(`/api/vm/{{ vm.name }}/snapshots`).then(r => r.json()).then(snaps => {
const el = document.getElementById('snapshot-list');
if (!snaps.length) {
el.innerHTML = '<p style="color:var(--text-secondary)">スナップショットはありません</p>';
return;
}
let html = '<table><thead><tr><th>名前</th><th>状態</th><th>作成日時</th><th>説明</th><th></th></tr></thead><tbody>';
snaps.forEach(s => {
const date = s.creation ? new Date(s.creation * 1000).toLocaleString('ja-JP') : '-';
const stateLabel = s.state === 'running' ? '稼働中' : s.state === 'shutoff' ? '停止中' : s.state;
html += `<tr>`;
html += `<td><strong>${s.name}</strong></td>`;
html += `<td><span class="status-badge status-${s.state === 'running' ? 'running' : 'stopped'}">${stateLabel}</span></td>`;
html += `<td style="font-size:0.85em">${date}</td>`;
html += `<td style="font-size:0.85em;color:var(--text-secondary)">${s.description || '-'}</td>`;
html += `<td style="white-space:nowrap">`;
html += `<button class="btn btn-info btn-sm" onclick="revertSnapshot('${s.name}')" title="元に戻す"><i class="fas fa-undo"></i></button> `;
html += `<button class="btn btn-danger btn-sm" onclick="deleteSnapshot('${s.name}')" title="削除"><i class="fas fa-trash"></i></button>`;
html += `</td></tr>`;
});
html += '</tbody></table>';
el.innerHTML = html;
}).catch(() => {
document.getElementById('snapshot-list').innerHTML = '<p style="color:var(--text-secondary)">取得できません</p>';
});
}
function createSnapshot() {
let existing = document.getElementById('snapshot-create-modal');
if (existing) existing.remove();
const modal = document.createElement('div');
modal.id = 'snapshot-create-modal';
modal.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;z-index:1000';
modal.innerHTML = `
<div style="background:var(--bg-card);border-radius:8px;padding:20px;min-width:380px;max-width:90vw">
<h3 style="margin-top:0">スナップショット作成</h3>
<div class="form-group">
<label>名前</label>
<input type="text" id="snap-name" placeholder="空なら自動生成" style="width:100%">
</div>
<div class="form-group">
<label>説明</label>
<input type="text" id="snap-desc" placeholder="オプション" style="width:100%">
</div>
<div style="text-align:right;margin-top:15px">
<button type="button" class="btn btn-secondary" onclick="document.getElementById('snapshot-create-modal').remove()" style="margin-right:8px">キャンセル</button>
<button type="button" class="btn btn-primary" onclick="confirmCreateSnapshot()">作成</button>
</div>
</div>`;
document.body.appendChild(modal);
}
function confirmCreateSnapshot() {
const name = document.getElementById('snap-name').value.trim();
const desc = document.getElementById('snap-desc').value.trim();
if (!confirm('スナップショットを作成しますか?')) return;
document.getElementById('snapshot-create-modal').remove();
fetch(`/api/vm/{{ vm.name }}/snapshot-create`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name: name, description: desc})
})
.then(r => r.json())
.then(d => { if (d.error) alert('エラー: ' + d.error); else { alert('作成しました'); loadSnapshots(); }})
.catch(err => alert('通信エラー: ' + err));
}
function revertSnapshot(snapName) {
if (!confirm(`スナップショット「${snapName}」に元に戻しますか?\n現在の状態は失われます。`)) return;
fetch(`/api/vm/{{ vm.name }}/snapshot-revert`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name: snapName})
})
.then(r => r.json())
.then(d => { if (d.error) alert('エラー: ' + d.error); else { alert('元に戻しました'); location.reload(); }})
.catch(err => alert('通信エラー: ' + err));
}
function deleteSnapshot(snapName) {
if (!confirm(`スナップショット「${snapName}」を削除しますか?`)) return;
fetch(`/api/vm/{{ vm.name }}/snapshot-delete`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name: snapName})
})
.then(r => r.json())
.then(d => { if (d.error) alert('エラー: ' + d.error); else { alert('削除しました'); loadSnapshots(); }})
.catch(err => alert('通信エラー: ' + err));
}
</script>
{% if is_active %}
<script type="module">
import RFB from '/novnc/core/rfb.js';
const vmName = "{{ vm.name }}";
let rfb = null;
let wsPort = null;
let reconnectTimer = null;
let reconnectDelay = 1000;
const vncStatus = document.getElementById('vnc-status');
const vncContainer = document.getElementById('vnc-container');
const btnFS = document.getElementById('vnc-btn-fullscreen');
function setVncStatus(text, color) {
if (vncStatus) {
vncStatus.textContent = text;
vncStatus.style.background = color;
}
}
function cleanupRfb() {
if (rfb) {
rfb.removeEventListener('connected', onConnected);
rfb.removeEventListener('disconnected', onDisconnected);
rfb.removeEventListener('failed', onFailed);
rfb.disconnect();
rfb = null;
}
}
function onConnected() {
setVncStatus('接続済み', '#27ae60');
reconnectDelay = 1000;
}
function onDisconnected() {
setVncStatus('切断 - 再接続中...', '#f39c12');
scheduleReconnect();
}
function onFailed() {
setVncStatus('接続失敗 - 再試行中...', '#e74c3c');
scheduleReconnect();
}
function scheduleReconnect() {
if (reconnectTimer) clearTimeout(reconnectTimer);
reconnectTimer = setTimeout(() => {
reconnectDelay = Math.min(reconnectDelay * 1.5, 10000);
connectVnc();
}, reconnectDelay);
}
async function connectVnc() {
try {
cleanupRfb();
setVncStatus('プロキシ起動中...', '#f39c12');
const resp = await fetch(`/api/vm/${vmName}/console-proxy`, { method: 'POST' });
const data = await resp.json();
if (data.error) {
setVncStatus('エラー: ' + data.error, '#e74c3c');
scheduleReconnect();
return;
}
wsPort = data.ws_port;
setVncStatus('接続中...', '#f39c12');
const wsUrl = `ws://${location.hostname}:${wsPort}`;
rfb = new RFB(vncContainer, wsUrl, {
credentials: {},
wsProtocols: ['binary'],
});
rfb.addEventListener('connected', onConnected);
rfb.addEventListener('disconnected', onDisconnected);
rfb.addEventListener('failed', onFailed);
rfb.scaleViewport = true;
rfb.resizeSession = true;
} catch (e) {
setVncStatus('エラー: ' + e.message, '#e74c3c');
scheduleReconnect();
}
}
btnFS.addEventListener('click', () => {
if (!vncContainer) return;
if (vncContainer.requestFullscreen) vncContainer.requestFullscreen();
else if (vncContainer.webkitRequestFullscreen) vncContainer.webkitRequestFullscreen();
});
window.addEventListener('beforeunload', () => {
if (reconnectTimer) clearTimeout(reconnectTimer);
cleanupRfb();
});
connectVnc();
window.cleanupRfb = cleanupRfb;
window.connectVnc = connectVnc;
setTimeout(() => { cleanupRfb(); connectVnc(); }, 500);
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
setTimeout(() => { cleanupRfb(); connectVnc(); }, 200);
}
});
const statusPoll = setInterval(async () => {
try {
const resp = await fetch(`/api/vm/${vmName}/status`);
const data = await resp.json();
if (!data.active) {
clearInterval(statusPoll);
location.reload();
}
} catch(e) {}
}, 3000);
</script>
{% endif %}
{% endblock %}
ENDOFFILE_VM_DETAIL_HTML
# Write templates/vm_console.html
cat << 'ENDOFFILE_VM_CONSOLE_HTML' > /opt/vm-manage/templates/vm_console.html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ vm_name }} - Console</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #1a1a2e; color: #eee; font-family: -apple-system, sans-serif; overflow: hidden; height: 100vh; display: flex; flex-direction: column; }
.toolbar {
display: flex; align-items: center; gap: 10px; padding: 8px 16px;
background: #16213e; border-bottom: 1px solid #333; z-index: 10;
}
.toolbar h1 { font-size: 1em; font-weight: 500; flex: 1; }
.toolbar .status { font-size: 0.85em; padding: 3px 10px; border-radius: 4px; }
.toolbar .status.connected { background: #27ae60; color: #fff; }
.toolbar .status.connecting { background: #f39c12; color: #fff; }
.toolbar .status.error { background: #e74c3c; color: #fff; }
.toolbar button {
background: #0f3460; color: #eee; border: 1px solid #555;
padding: 5px 12px; border-radius: 4px; cursor: pointer; font-size: 0.85em;
}
.toolbar button:hover { background: #1a508b; }
#console-container { flex: 1; display: flex; align-items: center; justify-content: center; position: relative; background: #000; }
#noVNC_canvas { max-width: 100%; max-height: 100%; }
.loading { text-align: center; color: #888; }
.loading .spinner { display: inline-block; width: 40px; height: 40px; border: 3px solid #333; border-top-color: #3498db; border-radius: 50%; animation: spin 1s linear infinite; margin-bottom: 15px; }
@keyframes spin { to { transform: rotate(360deg); } }
</style>
</head>
<body>
<div class="toolbar">
<h1>{{ vm_name }} - VNC Console</h1>
<span id="status" class="status connecting">接続中...</span>
<button id="btn-cad" disabled>Ctrl+Alt+Del</button>
<button id="btn-paste" disabled>クリップボード</button>
<button onclick="doDisconnect(); window.close();">閉じる</button>
</div>
<div id="console-container">
<div class="loading" id="loading">
<div class="spinner"></div>
<div>VNCに接続中...</div>
</div>
</div>
<script type="module">
import RFB from '/novnc/core/rfb.js';
const vmName = "{{ vm_name }}";
const statusEl = document.getElementById('status');
const loading = document.getElementById('loading');
const btnCAD = document.getElementById('btn-cad');
const btnPaste = document.getElementById('btn-paste');
let rfb = null;
function setStatus(text, cls) {
statusEl.textContent = text;
statusEl.className = 'status ' + cls;
}
async function connect() {
try {
setStatus('プロキシ起動中...', 'connecting');
const resp = await fetch(`/api/vm/${vmName}/console-proxy`, { method: 'POST' });
const data = await resp.json();
if (data.error) { setStatus('エラー: ' + data.error, 'error'); return; }
setStatus('接続中...', 'connecting');
const wsUrl = `ws://${location.hostname}:${data.ws_port}`;
const container = document.getElementById('console-container');
const canvas = document.createElement('canvas');
canvas.id = 'noVNC_canvas';
container.appendChild(canvas);
rfb = new RFB(container, wsUrl, {
credentials: {},
wsProtocols: ['binary'],
});
rfb.addEventListener('connected', () => {
setStatus('接続済み', 'connected');
loading.style.display = 'none';
btnCAD.disabled = false;
btnPaste.disabled = false;
});
rfb.addEventListener('disconnected', () => {
setStatus('切断', 'error');
btnCAD.disabled = true;
btnPaste.disabled = true;
});
rfb.addEventListener('failed', () => {
setStatus('接続失敗', 'error');
});
rfb.scaleViewport = true;
rfb.resizeSession = true;
} catch (e) {
setStatus('エラー: ' + e.message, 'error');
}
}
function doDisconnect() {
if (rfb) { rfb.disconnect(); rfb = null; }
fetch(`/api/vm/${vmName}/console-proxy`, { method: 'DELETE' }).catch(() => {});
}
btnCAD.addEventListener('click', () => { if (rfb) rfb.sendCtrlAltDel(); });
btnPaste.addEventListener('click', async () => {
const text = prompt('貼り付けテキスト:');
if (text && rfb) rfb.clipboardPasteFrom(text);
});
window.addEventListener('beforeunload', () => { doDisconnect(); });
connect();
</script>
</body>
</html>
ENDOFFILE_VM_CONSOLE_HTML
# Write static/css/style.css
cat << 'ENDOFFILE_STYLE_CSS' > /opt/vm-manage/static/css/style.css
:root {
--bg-dark: #1a1a2e;
--bg-card: #16213e;
--bg-sidebar: #0f3460;
--accent: #e94560;
--accent-hover: #ff6b81;
--text-primary: #eaeaea;
--text-secondary: #a0a0b0;
--border: #2a2a4a;
--success: #2ecc71;
--warning: #f39c12;
--danger: #e74c3c;
--info: #3498db;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: var(--bg-dark);
color: var(--text-primary);
display: flex;
min-height: 100vh;
}
.sidebar {
width: 240px;
background: var(--bg-sidebar);
border-right: 1px solid var(--border);
position: fixed;
height: 100vh;
overflow-y: auto;
}
.sidebar-header {
padding: 20px;
font-size: 1.3em;
font-weight: bold;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 10px;
}
.sidebar-header i { color: var(--accent); }
.sidebar-menu {
list-style: none;
padding: 10px 0;
}
.sidebar-menu li a {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 20px;
color: var(--text-secondary);
text-decoration: none;
transition: all 0.2s;
}
.sidebar-menu li a:hover,
.sidebar-menu li a.active {
background: rgba(233, 69, 96, 0.15);
color: var(--text-primary);
border-right: 3px solid var(--accent);
}
.content {
margin-left: 240px;
padding: 30px;
flex: 1;
min-width: 0;
}
h1 { font-size: 1.8em; margin-bottom: 20px; }
h2 { font-size: 1.4em; margin-bottom: 15px; color: var(--text-primary); }
h3 { font-size: 1.1em; margin-bottom: 10px; color: var(--text-secondary); }
.alert {
padding: 12px 18px;
border-radius: 6px;
margin-bottom: 20px;
font-size: 0.95em;
}
.alert-error { background: rgba(231, 76, 60, 0.2); border: 1px solid var(--danger); color: var(--danger); }
.alert-success { background: rgba(46, 204, 113, 0.2); border: 1px solid var(--success); color: var(--success); }
.alert-warning { background: rgba(243, 156, 18, 0.2); border: 1px solid var(--warning); color: var(--warning); }
.card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
padding-bottom: 10px;
border-bottom: 1px solid var(--border);
}
.card-header h2 { margin: 0; }
.grid { display: grid; gap: 20px; }
.grid-2 { grid-template-columns: repeat(2, 1fr); }
.grid-3 { grid-template-columns: repeat(3, 1fr); }
.vm-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 20px;
transition: transform 0.2s, border-color 0.2s;
}
.vm-card:hover {
transform: translateY(-2px);
border-color: var(--accent);
}
.vm-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.vm-card-header h3 { margin: 0; font-size: 1.2em; }
.status-badge {
display: inline-block;
padding: 3px 10px;
border-radius: 12px;
font-size: 0.8em;
font-weight: bold;
text-transform: uppercase;
}
.status-running { background: rgba(46, 204, 113, 0.2); color: var(--success); }
.status-stopped { background: rgba(160, 160, 176, 0.2); color: var(--text-secondary); }
.vm-info { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 15px; }
.vm-info dt { color: var(--text-secondary); font-size: 0.85em; }
.vm-info dd { font-weight: 500; }
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.9em;
font-weight: 500;
transition: background 0.2s;
text-decoration: none;
}
.btn-primary { background: var(--accent); color: white; }
.btn-primary:hover { background: var(--accent-hover); }
.btn-success { background: var(--success); color: white; }
.btn-success:hover { background: #27ae60; }
.btn-warning { background: var(--warning); color: white; }
.btn-warning:hover { background: #e67e22; }
.btn-danger { background: var(--danger); color: white; }
.btn-danger:hover { background: #c0392b; }
.btn-info { background: var(--info); color: white; }
.btn-info:hover { background: #2980b9; }
.btn-secondary { background: var(--border); color: var(--text-primary); }
.btn-secondary:hover { background: #3a3a5a; }
.btn-sm { padding: 5px 10px; font-size: 0.8em; }
.btn-group { display: flex; gap: 8px; flex-wrap: wrap; }
table {
width: 100%;
border-collapse: collapse;
font-size: 0.9em;
}
th, td {
text-align: left;
padding: 10px 14px;
border-bottom: 1px solid var(--border);
}
th {
color: var(--text-secondary);
font-weight: 600;
font-size: 0.85em;
text-transform: uppercase;
}
tr:hover { background: rgba(255, 255, 255, 0.03); }
.form-group { margin-bottom: 18px; }
.form-group label {
display: block;
margin-bottom: 6px;
font-weight: 500;
color: var(--text-secondary);
font-size: 0.9em;
}
.form-group input,
.form-group select,
.form-group textarea {
width: 100%;
padding: 10px 14px;
background: var(--bg-dark);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-primary);
font-size: 0.95em;
}
.form-group input:focus,
.form-group select:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--accent);
}
.form-group small {
display: block;
margin-top: 4px;
color: var(--text-secondary);
font-size: 0.8em;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 15px;
}
.form-row-3 {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 15px;
}
.checkbox-group {
display: flex;
align-items: center;
gap: 8px;
margin-top: 6px;
}
.checkbox-group input[type="checkbox"] {
width: auto;
}
.tab-nav {
display: flex;
gap: 0;
border-bottom: 2px solid var(--border);
margin-bottom: 20px;
}
.tab-nav button {
padding: 10px 20px;
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
font-size: 0.95em;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
transition: all 0.2s;
}
.tab-nav button.active {
color: var(--accent);
border-bottom-color: var(--accent);
}
.tab-panel { display: none; }
.tab-panel.active { display: block; }
.xml-editor {
width: 100%;
min-height: 400px;
background: #0d1117;
color: #c9d1d9;
border: 1px solid var(--border);
border-radius: 6px;
padding: 15px;
font-family: 'Fira Code', 'Consolas', monospace;
font-size: 0.85em;
line-height: 1.5;
resize: vertical;
tab-size: 2;
}
.disk-item {
background: var(--bg-dark);
border: 1px solid var(--border);
border-radius: 6px;
padding: 15px;
margin-bottom: 10px;
position: relative;
}
.disk-item .remove-btn {
position: absolute;
top: 10px;
right: 10px;
background: none;
border: none;
color: var(--danger);
cursor: pointer;
font-size: 1.1em;
}
@media (max-width: 900px) {
.sidebar { width: 60px; }
.sidebar-header span, .sidebar-menu li a span { display: none; }
.sidebar-menu li a { justify-content: center; padding: 12px; }
.content { margin-left: 60px; }
.grid-2, .grid-3 { grid-template-columns: 1fr; }
.form-row, .form-row-3 { grid-template-columns: 1fr; }
}
@keyframes spin { to { transform: rotate(360deg); } }
ENDOFFILE_STYLE_CSS
# Write static/js/app.js
cat << 'ENDOFFILE_APP_JS' > /opt/vm-manage/static/js/app.js
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('textarea').forEach(ta => {
ta.addEventListener('keydown', function(e) {
if (e.key === 'Tab') {
e.preventDefault();
const start = this.selectionStart;
const end = this.selectionEnd;
this.value = this.value.substring(0, start) + ' ' + this.value.substring(end);
this.selectionStart = this.selectionEnd = start + 2;
}
});
});
});
ENDOFFILE_APP_JS
# Create venv
echo "Creating virtual environment..."
cd /opt/vm-manage
python3 -m venv --system-site-packages venv
# Install Python packages
echo "Installing Python packages..."
/opt/vm-manage/venv/bin/pip install flask libvirt-python
# Create systemd service
cat << 'ENDOFFILE_SERVICE' > /etc/systemd/system/vm-manage.service
[Unit]
Description=VM Manager Web Application
After=network.target libvirtd.service
[Service]
Type=simple
User=root
WorkingDirectory=/opt/vm-manage
ExecStart=/opt/vm-manage/venv/bin/python /opt/vm-manage/app.py
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
ENDOFFILE_SERVICE
# Update port in app.py
sed -i "s/port=8090/port=${PORT}/" /opt/vm-manage/app.py
# Reload and enable services
systemctl daemon-reload
systemctl enable vm-manage
systemctl enable libvirtd 2>/dev/null || true
systemctl start libvirtd 2>/dev/null || true
# Check OVMF files
echo "Checking OVMF files..."
if [ -f /usr/share/OVMF/OVMF_CODE_4M.ms.fd ] || [ -f /usr/share/OVMF/OVMF_CODE_4M.fd ]; then
echo "OVMF files found."
else
echo "Warning: OVMF files not found. UEFI VMs may not work."
fi
echo ""
echo "Starting vm-manage service..."
systemctl start vm-manage
sleep 2
if systemctl is-active --quiet vm-manage; then
echo "vm-manage is running!"
else
echo "Warning: vm-manage failed to start. Check: journalctl -u vm-manage"
fi
echo ""
echo "Installation complete!"
echo "Access VM Manager at: http://$(hostname -I | awk '{print $1}'):${PORT}"
echo ""
echo "To check status: systemctl status vm-manage"
VM作成や編集から運用まで
機能を追加していったので整合性がなく画面がごちゃごちゃしたのが反省点なのですが。
ひとまず実用的にはなったのかなと。
新規作成や既存マシンの変更はしやすいのではと思います。
ディスクの拡張なども行えます。
VNCの画面表示はまだ改善の余地がありまくりです(起動途中の画面表示がされなかったりとか)。
もし/opt/isoのパーミッションエラーとかの場合はこちらで対応を。




