1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
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()
}
|