aboutsummaryrefslogtreecommitdiffstats
path: root/vm-manager/Virsh.qml
diff options
context:
space:
mode:
Diffstat (limited to 'vm-manager/Virsh.qml')
-rw-r--r--vm-manager/Virsh.qml308
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()
+}