diff options
Diffstat (limited to 'vm-manager/Virsh.qml')
| -rw-r--r-- | vm-manager/Virsh.qml | 308 |
1 files changed, 308 insertions, 0 deletions
diff --git a/vm-manager/Virsh.qml b/vm-manager/Virsh.qml new file mode 100644 index 0000000..309781c --- /dev/null +++ b/vm-manager/Virsh.qml @@ -0,0 +1,308 @@ +// Copyright (C) 2026 Danilo M. <danix@danix.xyz> +// +// This program is free software; you can redistribute it and/or modify +// it under the terms of the GNU General Public License version 2 as +// published by the Free Software Foundation. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +pragma Singleton + +import Quickshell +import Quickshell.Io +import QtQuick + +// Everything that talks to libvirt. Shelling out to virsh rather than binding +// libvirt: the commands are the same ones the old rofi script used, and this +// adds no dependency. +Singleton { + id: root + + // name -> { state, cpu, memUsed, memTotal, fsUsed, fsTotal, ip, vcpus, agent } + property var vms: ({}) + property var names: [] + property var snapshots: ({}) // name -> [{ name, created, state, current }] + + // Polling only runs while the panel is open; the event stream always does. + property bool sampling: false + + signal actionFailed(string vm, string action, string message) + + function _vm(name) { + return vms[name] ?? { state: "unknown", cpu: -1, memUsed: -1, memTotal: -1, + fsUsed: -1, fsTotal: -1, ip: "", vcpus: 0, agent: false }; + } + + function _set(name, fields) { + const next = Object.assign({}, vms); + next[name] = Object.assign({}, _vm(name), fields); + vms = next; + } + + // --- discovery ------------------------------------------------------- + + Process { + id: listProc + command: ["virsh", "list", "--all", "--name"] + stdout: StdioCollector { + onStreamFinished: { + const found = text.trim().split("\n").map(s => s.trim()).filter(s => s.length); + root.names = found; + for (const n of found) if (!(n in root.vms)) root._set(n, {}); + root.refresh(); + } + } + } + + // --- state + stats --------------------------------------------------- + + // cpu.time is cumulative nanoseconds, so a percentage needs two samples. + property var _lastCpu: ({}) + + Process { + id: statsProc + command: ["virsh", "domstats", "--state", "--cpu-total", "--balloon", "--vcpu"] + stdout: StdioCollector { + onStreamFinished: { + const now = Date.now(); + let vm = null; + for (const line of text.split("\n")) { + const dom = line.match(/^Domain: '(.+)'$/); + if (dom) { vm = dom[1]; continue; } + if (!vm) continue; + const kv = line.trim().match(/^([\w.]+)=(.+)$/); + if (!kv) continue; + const [, key, val] = kv; + + if (key === "state.state") { + root._set(vm, { state: root._stateName(parseInt(val)) }); + } else if (key === "vcpu.current") { + root._set(vm, { vcpus: parseInt(val) }); + } else if (key === "cpu.time") { + const ns = parseInt(val); + const prev = root._lastCpu[vm]; + if (prev && now > prev.at) { + const vcpus = root._vm(vm).vcpus || 1; + const pct = (ns - prev.ns) / ((now - prev.at) * 1e6) / vcpus * 100; + root._set(vm, { cpu: Math.max(0, Math.min(100, pct)) }); + } + const c = Object.assign({}, root._lastCpu); + c[vm] = { ns: ns, at: now }; + root._lastCpu = c; + } + } + root._pollAgents(); + } + } + } + + function _stateName(n) { + // libvirt VIR_DOMAIN_* enum + return ({ 1: "running", 2: "blocked", 3: "paused", 4: "shutting down", + 5: "shut off", 6: "crashed", 7: "suspended" })[n] ?? "unknown"; + } + + // --- guest agent ----------------------------------------------------- + // + // Memory, filesystem usage and IP all come from qemu-guest-agent. It is + // not up while the VM boots, and a VM may not have it at all, so every + // one of these failing is ordinary: the rows show a dash rather than + // falling back to a host-side number that means something different. + + property var _agentQueue: [] + + function _pollAgents() { + _agentQueue = names.filter(n => _vm(n).state === "running"); + _nextAgent(); + } + + function _nextAgent() { + if (_agentQueue.length === 0) return; + const vm = _agentQueue[0]; + _agentQueue = _agentQueue.slice(1); + memProc.vm = vm; + memProc.command = ["virsh", "dommemstat", vm]; + memProc.running = true; + } + + Process { + id: memProc + property string vm: "" + stdout: StdioCollector { + onStreamFinished: { + const get = k => { + const m = text.match(new RegExp("^" + k + " (\\d+)$", "m")); + return m ? parseInt(m[1]) * 1024 : -1; + }; + const total = get("actual"), usable = get("usable"); + if (total > 0 && usable > 0) { + memProc.vmSet({ memUsed: total - usable, memTotal: total, agent: true }); + } else { + memProc.vmSet({ memUsed: -1, memTotal: total, agent: false }); + } + fsProc.vm = memProc.vm; + fsProc.command = ["virsh", "qemu-agent-command", memProc.vm, + '{"execute":"guest-get-fsinfo"}']; + fsProc.running = true; + } + } + function vmSet(f) { root._set(vm, f); } + onExited: code => { if (code !== 0) root._set(vm, { agent: false, memUsed: -1 }); } + } + + Process { + id: fsProc + property string vm: "" + stdout: StdioCollector { + onStreamFinished: { + try { + const fs = JSON.parse(text).return; + // The root filesystem is the one worth showing; the ESP is noise. + const r = fs.find(f => f.mountpoint === "/") ?? fs[0]; + if (r && r["total-bytes"] > 0) + root._set(fsProc.vm, { fsUsed: r["used-bytes"], fsTotal: r["total-bytes"] }); + } catch (e) { + root._set(fsProc.vm, { fsUsed: -1, fsTotal: -1 }); + } + ipProc.vm = fsProc.vm; + ipProc.command = ["virsh", "domifaddr", fsProc.vm, "--source", "agent"]; + ipProc.running = true; + } + } + onExited: code => { if (code !== 0) root._set(vm, { fsUsed: -1, fsTotal: -1 }); } + } + + Process { + id: ipProc + property string vm: "" + stdout: StdioCollector { + onStreamFinished: { + // Skip loopback and link-local; the first real v4 address wins. + const m = text.match(/\s(\d+\.\d+\.\d+\.\d+)\/\d+/g) ?? []; + const ip = m.map(s => s.trim().split("/")[0]) + .find(a => !a.startsWith("127.")) ?? ""; + root._set(ipProc.vm, { ip: ip }); + root._nextAgent(); + } + } + onExited: code => { if (code !== 0) { root._set(vm, { ip: "" }); root._nextAgent(); } } + } + + // --- snapshots ------------------------------------------------------- + + Process { + id: snapProc + property string vm: "" + stdout: StdioCollector { + onStreamFinished: { + const rows = []; + for (const line of text.split("\n").slice(2)) { + // Name, then creation date and time, then state. + const m = line.trim().match(/^(\S+)\s+(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})[^\s]*\s*\S*\s+(\S+)/); + if (m) rows.push({ name: m[1], created: m[2], state: m[3] }); + } + const next = Object.assign({}, root.snapshots); + next[snapProc.vm] = rows; + root.snapshots = next; + } + } + } + + function loadSnapshots(vm) { + snapProc.vm = vm; + snapProc.command = ["virsh", "snapshot-list", vm]; + snapProc.running = true; + } + + // --- actions --------------------------------------------------------- + + readonly property var _cmds: ({ + start: v => ["virsh", "start", v], + shutdown: v => ["virsh", "shutdown", "--mode", "acpi", "--domain", v], + reboot: v => ["virsh", "reboot", v], + reset: v => ["virsh", "reset", v], + destroy: v => ["virsh", "destroy", v], + suspend: v => ["virsh", "suspend", v], + resume: v => ["virsh", "resume", v], + }) + + function act(vm, action) { + actProc.vm = vm; actProc.action = action; + actProc.command = _cmds[action](vm); + actProc.running = true; + } + + function snapshotCreate(vm) { + const ts = Qt.formatDateTime(new Date(), "ddMMyyyy_HHmmss"); + actProc.vm = vm; actProc.action = "snapshot"; + actProc.command = ["virsh", "snapshot-create-as", "--domain", vm, + "--name", `${vm}_${ts}`]; + actProc.running = true; + } + + function snapshotRevert(vm, snap) { + actProc.vm = vm; actProc.action = "revert"; + actProc.command = ["virsh", "snapshot-revert", vm, snap]; + actProc.running = true; + } + + function snapshotDelete(vm, snap) { + actProc.vm = vm; actProc.action = "snapshot delete"; + actProc.command = ["virsh", "snapshot-delete", vm, snap]; + actProc.running = true; + } + + // Undefine with --remove-all-storage erases the disk image. The panel + // requires the VM's name typed before it will call this. + function deleteVm(vm) { + actProc.vm = vm; actProc.action = "delete"; + actProc.command = ["sh", "-c", + `virsh destroy ${JSON.stringify(vm)} 2>/dev/null; ` + + `virsh undefine ${JSON.stringify(vm)} --remove-all-storage`]; + actProc.running = true; + } + + Process { + id: actProc + property string vm: "" + property string action: "" + stderr: StdioCollector { id: actErr } + onExited: code => { + if (code !== 0) + root.actionFailed(actProc.vm, actProc.action, + actErr.text.trim() || `exited ${code}`); + root.refreshList(); + if (root.snapshots[actProc.vm]) root.loadSnapshots(actProc.vm); + } + } + + // --- refresh --------------------------------------------------------- + + function refresh() { statsProc.running = true; } + function refreshList() { listProc.running = true; } + + Timer { + interval: 2000 + running: root.sampling + repeat: true + onTriggered: root.refresh() + } + + // libvirt pushes lifecycle changes, so a VM started from virt-manager or + // the CLI updates the panel too. This runs whether or not it is open, + // which is what makes the panel correct the moment it is shown. + Process { + running: true + command: ["virsh", "event", "--all", "--loop"] + stdout: SplitParser { + onRead: line => { + if (/event '(lifecycle|agent-lifecycle)'/.test(line)) root.refreshList(); + } + } + } + + Component.onCompleted: refreshList() +} |
