// Copyright (C) 2026 Danilo M. // // 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() }