diff options
| author | Danilo M. <danix@danix.xyz> | 2026-09-11 16:23:36 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-09-11 16:23:36 +0200 |
| commit | 1ffc9c8e8fd3b97f343d6e2360f030cb08b42a18 (patch) | |
| tree | 59d59bda8197a43009395b991dbc18fc93fe8a88 /vm-manager/Virsh.qml | |
| parent | 18fd64134f51ec99b123c91f79790c12ad5e1bb4 (diff) | |
| download | quickshell-1ffc9c8e8fd3b97f343d6e2360f030cb08b42a18.tar.gz quickshell-1ffc9c8e8fd3b97f343d6e2360f030cb08b42a18.zip | |
feat(vm-manager): libvirt VM drawer
A top drawer on the secondary monitor showing every libvirt machine: its
state, live statistics while it runs, the actions that make sense in that
state, and the snapshot list. SUPER+v toggles it over IPC.
This replaces the menu half of rofi-qemu.sh, which is still in ~/bin but
no longer bound. A dmenu row can only ever be a string, so the script
could not show what a machine is actually doing, and the snapshots it
could create were never listed anywhere.
Everything shells out to virsh, the same commands the script used, so
there is no libvirt binding and no new dependency. State changes arrive
by `virsh event --all --loop` rather than polling, which means a machine
started from virt-manager or the CLI updates the drawer too. Statistics
do need a 2 second timer, but it only runs while the drawer is open.
Memory, filesystem usage and the address come from qemu-guest-agent, so
they are absent for the first seconds after boot and on any machine
without the agent. Those rows show a dash rather than falling back to
libvirt's own figures: balloon.current is memory allocated rather than
used and reads full forever, and block.allocation is qcow2 growth on the
host rather than usage inside the guest. Either would be quietly wrong.
Destructive actions are gated, which the rofi version did not do at all:
Delete VM requires the name typed, since it runs undefine with
--remove-all-storage and erases the disk image, and reset, force stop,
snapshot revert and snapshot delete each take a confirmation click.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G7NRsGyF9jGfPYS4zPqpN7
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() +} |
